@pixelbyte-software/pixcode 1.51.4 → 1.51.6

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 (42) hide show
  1. package/dist/assets/index-Bbmw_Gy1.css +32 -0
  2. package/dist/assets/{index-HfGHXhD6.js → index-DodOzOl5.js} +182 -184
  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/index.js +530 -34
  7. package/dist-server/server/index.js.map +1 -1
  8. package/dist-server/server/middleware/auth.js +20 -3
  9. package/dist-server/server/middleware/auth.js.map +1 -1
  10. package/dist-server/server/modules/providers/provider.routes.js +33 -17
  11. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  12. package/dist-server/server/routes/commands.js +20 -9
  13. package/dist-server/server/routes/commands.js.map +1 -1
  14. package/dist-server/server/routes/live-view.js +30 -7
  15. package/dist-server/server/routes/live-view.js.map +1 -1
  16. package/dist-server/server/routes/messages.js +30 -0
  17. package/dist-server/server/routes/messages.js.map +1 -1
  18. package/dist-server/server/routes/network.js +3 -2
  19. package/dist-server/server/routes/network.js.map +1 -1
  20. package/dist-server/server/routes/platformization.js +17 -1
  21. package/dist-server/server/routes/platformization.js.map +1 -1
  22. package/dist-server/server/routes/projects.js +4 -3
  23. package/dist-server/server/routes/projects.js.map +1 -1
  24. package/dist-server/server/routes/remote.js +2 -1
  25. package/dist-server/server/routes/remote.js.map +1 -1
  26. package/dist-server/server/services/platformization.js +168 -1
  27. package/dist-server/server/services/platformization.js.map +1 -1
  28. package/package.json +1 -1
  29. package/scripts/update-git-install.mjs +15 -10
  30. package/server/cli.js +4 -4
  31. package/server/index.js +552 -34
  32. package/server/middleware/auth.js +26 -2
  33. package/server/modules/providers/provider.routes.ts +91 -53
  34. package/server/routes/commands.js +43 -32
  35. package/server/routes/live-view.js +47 -24
  36. package/server/routes/messages.js +47 -12
  37. package/server/routes/network.js +9 -8
  38. package/server/routes/platformization.js +27 -9
  39. package/server/routes/projects.js +7 -6
  40. package/server/routes/remote.js +6 -5
  41. package/server/services/platformization.js +196 -24
  42. 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,11 +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, requireAdmin } from './middleware/auth.js';
135
- import { filterProjectsForUser, userHasProjectAccess } from './services/platformization.js';
135
+ import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
136
+ import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
136
137
  import { IS_PLATFORM } from './constants/config.js';
137
138
 
138
139
  import { getConnectableHost } from '../shared/networkHosts.js';
@@ -140,6 +141,343 @@ import { getConnectableHost } from '../shared/networkHosts.js';
140
141
  import { buildDaemonCliCommand, handleDaemonCommand } from './daemon-manager.js';
141
142
 
142
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();
143
481
 
144
482
  function requireProjectAccess(capability = 'viewFiles') {
145
483
  return (req, res, next) => {
@@ -160,11 +498,11 @@ function requireProjectPathAccess(capability = 'viewFiles') {
160
498
  return (req, res, next) => {
161
499
  const projectPath = req.body?.projectPath || req.query.projectPath || os.homedir();
162
500
  const resolvedProjectPath = path.resolve(String(projectPath));
163
- if (!userHasProjectAccess(req.user, {
501
+ if (!userHasProjectPathAccess(req.user, {
164
502
  fullPath: resolvedProjectPath,
165
503
  path: resolvedProjectPath,
166
504
  projectPath: resolvedProjectPath,
167
- }, capability)) {
505
+ }, resolvedProjectPath, capability)) {
168
506
  return res.status(403).json({ error: 'Project access denied.' });
169
507
  }
170
508
 
@@ -484,12 +822,13 @@ function terminatePtySession(sessionKey, session, reason) {
484
822
  return true;
485
823
  }
486
824
 
487
- function killProviderPtySessions(projectPath, provider) {
825
+ function killProviderPtySessions(projectPath, provider, userId = null) {
488
826
  let killed = 0;
489
827
  for (const [sessionKey, session] of ptySessionsMap.entries()) {
490
828
  if (
491
829
  session?.projectPath === projectPath &&
492
830
  session?.provider === provider &&
831
+ (!userId || session?.userId === userId) &&
493
832
  !session?.isPlainShell
494
833
  ) {
495
834
  killed += terminatePtySession(sessionKey, session, 'fresh provider session') ? 1 : 0;
@@ -762,6 +1101,25 @@ function buildProviderShellCommand(command, permissionFlags = []) {
762
1101
  return flags.length > 0 ? `${command} ${flags.join(' ')}` : command;
763
1102
  }
764
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
+
765
1123
  function resolvePublicBaseUrl(request) {
766
1124
  const headers = request?.headers || {};
767
1125
  const forwardedProto = String(headers['x-forwarded-proto'] || '').split(',')[0].trim();
@@ -863,15 +1221,11 @@ const wss = new WebSocketServer({
863
1221
  verifyClient: (info) => {
864
1222
  console.log('WebSocket connection attempt to:', info.req.url);
865
1223
 
866
- // Platform mode: always allow connection
867
- if (IS_PLATFORM) {
868
- const user = authenticateWebSocket(null); // Will return first user
869
- if (!user) {
870
- console.log('[WARN] Platform mode: No user found in database');
871
- return false;
872
- }
1224
+ const platformBypassUser = IS_PLATFORM ? authenticateWebSocket(null) : null;
1225
+ if (platformBypassUser) {
1226
+ const user = platformBypassUser;
873
1227
  info.req.user = user;
874
- 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);
875
1229
  return true;
876
1230
  }
877
1231
 
@@ -917,11 +1271,14 @@ app.use(express.urlencoded({ limit: '50mb', extended: true }));
917
1271
 
918
1272
  // Public health check endpoint (no authentication required)
919
1273
  app.get('/health', (req, res) => {
1274
+ const updateState = readSystemUpdateState();
920
1275
  res.json({
921
1276
  status: 'ok',
922
1277
  timestamp: new Date().toISOString(),
923
1278
  installMode,
924
- version: SERVER_VERSION
1279
+ version: SERVER_VERSION,
1280
+ pendingRestart: updateState.pendingRestart,
1281
+ lastAppliedUpdate: updateState.lastAppliedUpdate,
925
1282
  });
926
1283
  });
927
1284
 
@@ -936,7 +1293,11 @@ app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathA
936
1293
  return res.status(400).json({ error: 'Unsupported provider' });
937
1294
  }
938
1295
 
939
- 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
+ );
940
1301
  res.json({ success: true, killedSessions });
941
1302
  });
942
1303
 
@@ -957,11 +1318,14 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
957
1318
  }
958
1319
 
959
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);
960
1323
  let matchedSession = null;
961
1324
  for (const session of ptySessionsMap.values()) {
962
1325
  if (
963
1326
  session?.provider === provider &&
964
1327
  !session?.isPlainShell &&
1328
+ (canReadAnyShellSession || session?.userId === requestUserId) &&
965
1329
  (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
966
1330
  (!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)
967
1331
  ) {
@@ -1012,6 +1376,8 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
1012
1376
  }
1013
1377
 
1014
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);
1015
1381
  let matchedSession = null;
1016
1382
  for (const session of ptySessionsMap.values()) {
1017
1383
  if (
@@ -1019,6 +1385,7 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
1019
1385
  !session?.isPlainShell &&
1020
1386
  session?.pty &&
1021
1387
  session.lifecycleState === 'running' &&
1388
+ (canWriteAnyShellSession || session?.userId === requestUserId) &&
1022
1389
  (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
1023
1390
  (!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)
1024
1391
  ) {
@@ -1135,14 +1502,14 @@ adapterRegistry.register(new QwenA2AAdapter());
1135
1502
  adapterRegistry.register(new OpenCodeA2AAdapter());
1136
1503
  adapterRegistry.register(new JsonEventA2AAdapter());
1137
1504
  app.use('/hermes', createHermesTaskRouter());
1138
- app.use('/preview', authenticateToken, createPreviewProxyRouter());
1139
- app.use('/api/orchestration', authenticateToken, createOrchestrationTaskRouter());
1140
- 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({
1141
1508
  appRoot: APP_ROOT,
1142
1509
  createHermesApiKey: getOrCreateHermesApiKey,
1143
1510
  resolvePublicBaseUrl,
1144
1511
  }));
1145
- app.use('/api/orchestration', authenticateToken, createWorkflowRouter());
1512
+ app.use('/api/orchestration', authenticateToken, requireAdmin, createWorkflowRouter());
1146
1513
  app.use('/live', createLiveViewPublicRouter());
1147
1514
 
1148
1515
  // Network discovery / QR endpoints (protected)
@@ -1193,6 +1560,38 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
1193
1560
  // /api/config endpoint removed - no longer needed
1194
1561
  // Frontend now uses window.location for WebSocket URLs
1195
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
+
1196
1595
  // System update endpoint — streams live output via Server-Sent Events so the
1197
1596
  // UI sees npm/git progress in real time instead of waiting ~2 minutes for the
1198
1597
  // buffered response.
@@ -1206,7 +1605,7 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
1206
1605
  // checkout state before pulling so source installs do not fail on local
1207
1606
  // modified files left by older releases or manual edits.
1208
1607
  // 3. fallback → `npm install -g …` (classic npm-distributed install).
1209
- app.post('/api/system/update', authenticateToken, async (req, res) => {
1608
+ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope('system:update'), async (req, res) => {
1210
1609
  const projectRoot = APP_ROOT;
1211
1610
  console.log('Starting system update from directory:', projectRoot);
1212
1611
 
@@ -1563,7 +1962,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
1563
1962
  // Restart endpoint — exits the current process so an external wrapper
1564
1963
  // (systemd/pm2/daemon manager) can bring the server back on the new code.
1565
1964
  // Foreground installs without a wrapper will simply stop; the UI reports this.
1566
- app.post('/api/system/restart', authenticateToken, (req, res) => {
1965
+ app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
1567
1966
  res.json({
1568
1967
  success: true,
1569
1968
  version: SERVER_VERSION,
@@ -1580,7 +1979,7 @@ app.post('/api/system/restart', authenticateToken, (req, res) => {
1580
1979
  app.get('/api/projects', authenticateToken, async (req, res) => {
1581
1980
  try {
1582
1981
  const projects = await getProjects(broadcastProgress);
1583
- res.json(filterProjectsForUser(projects, req.user));
1982
+ res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
1584
1983
  } catch (error) {
1585
1984
  res.status(500).json({ error: error.message });
1586
1985
  }
@@ -1590,6 +1989,9 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
1590
1989
  try {
1591
1990
  const { limit = 5, offset = 0 } = req.query;
1592
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
+ }
1593
1995
  applyCustomSessionNames(result.sessions, 'claude');
1594
1996
  res.json(result);
1595
1997
  } catch (error) {
@@ -1735,7 +2137,7 @@ const expandBrowsePath = (inputPath) => {
1735
2137
  };
1736
2138
 
1737
2139
  // Browse filesystem endpoint for project suggestions - uses existing getFileTree
1738
- app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
2140
+ app.get('/api/browse-filesystem', authenticateToken, requireAdmin, async (req, res) => {
1739
2141
  try {
1740
2142
  const { path: dirPath } = req.query;
1741
2143
 
@@ -1824,7 +2226,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
1824
2226
  }
1825
2227
  });
1826
2228
 
1827
- app.post('/api/create-folder', authenticateToken, async (req, res) => {
2229
+ app.post('/api/create-folder', authenticateToken, requireAdmin, async (req, res) => {
1828
2230
  try {
1829
2231
  const { path: folderPath } = req.body;
1830
2232
  if (!folderPath) {
@@ -1889,6 +2291,9 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
1889
2291
  if (!resolved.startsWith(normalizedRoot)) {
1890
2292
  return res.status(403).json({ error: 'Path must be under project root' });
1891
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
+ }
1892
2297
 
1893
2298
  const content = await fsPromises.readFile(resolved, 'utf8');
1894
2299
  res.json({ content, path: resolved });
@@ -1930,6 +2335,9 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
1930
2335
  if (!resolved.startsWith(normalizedRoot)) {
1931
2336
  return res.status(403).json({ error: 'Path must be under project root' });
1932
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
+ }
1933
2341
 
1934
2342
  // Check if file exists
1935
2343
  try {
@@ -1990,6 +2398,9 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
1990
2398
  if (!resolved.startsWith(normalizedRoot)) {
1991
2399
  return res.status(403).json({ error: 'Path must be under project root' });
1992
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
+ }
1993
2404
 
1994
2405
  // Write the new content
1995
2406
  await fsPromises.writeFile(resolved, content, 'utf8');
@@ -2038,7 +2449,12 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
2038
2449
  }
2039
2450
 
2040
2451
  const files = await getFileTree(actualPath, 10, 0, true);
2041
- 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'));
2042
2458
  } catch (error) {
2043
2459
  console.error('[ERROR] File tree error:', error.message);
2044
2460
  res.status(500).json({ error: error.message });
@@ -2127,6 +2543,9 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
2127
2543
  }
2128
2544
 
2129
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
+ }
2130
2549
 
2131
2550
  // Check if already exists
2132
2551
  try {
@@ -2198,6 +2617,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
2198
2617
  }
2199
2618
 
2200
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
+ }
2201
2623
 
2202
2624
  // Check if old path exists
2203
2625
  try {
@@ -2213,6 +2635,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
2213
2635
  if (!newValidation.valid) {
2214
2636
  return res.status(403).json({ error: newValidation.error });
2215
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
+ }
2216
2641
 
2217
2642
  // Check if new path already exists
2218
2643
  try {
@@ -2270,6 +2695,9 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
2270
2695
  }
2271
2696
 
2272
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
+ }
2273
2701
 
2274
2702
  // Check if path exists and get stats
2275
2703
  let stats;
@@ -2403,6 +2831,9 @@ const uploadFilesHandler = async (req, res) => {
2403
2831
  resolvedTargetDir = validation.resolved;
2404
2832
  console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
2405
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
+ }
2406
2837
 
2407
2838
  // Ensure target directory exists
2408
2839
  try {
@@ -2429,6 +2860,10 @@ const uploadFilesHandler = async (req, res) => {
2429
2860
  await fsPromises.unlink(file.path).catch(() => {});
2430
2861
  continue;
2431
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
+ }
2432
2867
 
2433
2868
  // Ensure parent directory exists (for nested files from folder upload)
2434
2869
  const parentDir = path.dirname(destPath);
@@ -2552,15 +2987,35 @@ class WebSocketWriter {
2552
2987
  this.ws = ws;
2553
2988
  this.sessionId = null;
2554
2989
  this.userId = userId;
2990
+ this.provider = 'claude';
2991
+ this.projectName = null;
2992
+ this.projectPath = null;
2555
2993
  this.isWebSocketWriter = true; // Marker for transport detection
2556
2994
  }
2557
2995
 
2558
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
+ }
2559
3008
  if (this.ws.readyState === 1) { // WebSocket.OPEN
2560
3009
  this.ws.send(JSON.stringify(data));
2561
3010
  }
2562
3011
  }
2563
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
+
2564
3019
  updateWebSocket(newRawWs) {
2565
3020
  this.ws = newRawWs;
2566
3021
  }
@@ -2585,6 +3040,45 @@ function handleChatConnection(ws, request) {
2585
3040
 
2586
3041
  // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
2587
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
+ };
2588
3082
 
2589
3083
  ws.on('message', async (message) => {
2590
3084
  try {
@@ -2601,25 +3095,38 @@ function handleChatConnection(ws, request) {
2601
3095
  : () => {};
2602
3096
 
2603
3097
  if (data.type === 'claude-command') {
3098
+ assertChatCommandAccess(data);
3099
+ setWriterCommandContext('claude', data);
2604
3100
  chatDebug('claude', data);
2605
3101
  // Use Claude Agents SDK
2606
3102
  await queryClaudeSDK(data.command, data.options, writer);
2607
3103
  } else if (data.type === 'cursor-command') {
3104
+ assertChatCommandAccess(data);
3105
+ setWriterCommandContext('cursor', data);
2608
3106
  chatDebug('cursor', data);
2609
3107
  await spawnCursor(data.command, data.options, writer);
2610
3108
  } else if (data.type === 'codex-command') {
3109
+ assertChatCommandAccess(data);
3110
+ setWriterCommandContext('codex', data);
2611
3111
  chatDebug('codex', data);
2612
3112
  await queryCodex(data.command, data.options, writer);
2613
3113
  } else if (data.type === 'gemini-command') {
3114
+ assertChatCommandAccess(data);
3115
+ setWriterCommandContext('gemini', data);
2614
3116
  chatDebug('gemini', data);
2615
3117
  await spawnGemini(data.command, data.options, writer);
2616
3118
  } else if (data.type === 'qwen-command') {
3119
+ assertChatCommandAccess(data);
3120
+ setWriterCommandContext('qwen', data);
2617
3121
  chatDebug('qwen', data);
2618
3122
  await spawnQwen(data.command, data.options, writer);
2619
3123
  } else if (data.type === 'opencode-command') {
3124
+ assertChatCommandAccess(data);
3125
+ setWriterCommandContext('opencode', data);
2620
3126
  chatDebug('opencode', data);
2621
3127
  await spawnOpencode(data.command, data.options, writer);
2622
3128
  } else if (data.type === 'cursor-resume') {
3129
+ assertChatCommandAccess({ options: { cwd: data.options?.cwd } });
2623
3130
  // Backward compatibility: treat as cursor-command with resume and no prompt
2624
3131
  console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
2625
3132
  await spawnCursor('', {
@@ -2628,6 +3135,7 @@ function handleChatConnection(ws, request) {
2628
3135
  cwd: data.options?.cwd
2629
3136
  }, writer);
2630
3137
  } else if (data.type === 'abort-session') {
3138
+ requireAdminChatAction('Aborting sessions');
2631
3139
  console.log('[DEBUG] Abort session request:', data.sessionId);
2632
3140
  const provider = data.provider || 'claude';
2633
3141
  let success;
@@ -2649,6 +3157,7 @@ function handleChatConnection(ws, request) {
2649
3157
 
2650
3158
  writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
2651
3159
  } else if (data.type === 'claude-permission-response') {
3160
+ requireAdminChatAction('Resolving tool approvals');
2652
3161
  // Relay UI approval decisions back into the SDK control flow.
2653
3162
  // This does not persist permissions; it only resolves the in-flight request,
2654
3163
  // introduced so the SDK can resume once the user clicks Allow/Deny.
@@ -2661,10 +3170,12 @@ function handleChatConnection(ws, request) {
2661
3170
  });
2662
3171
  }
2663
3172
  } else if (data.type === 'cursor-abort') {
3173
+ requireAdminChatAction('Aborting sessions');
2664
3174
  console.log('[DEBUG] Abort Cursor session:', data.sessionId);
2665
3175
  const success = abortCursorSession(data.sessionId);
2666
3176
  writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' }));
2667
3177
  } else if (data.type === 'check-session-status') {
3178
+ requireAdminChatAction('Checking global session status');
2668
3179
  // Check if a specific session is currently processing
2669
3180
  const provider = data.provider || 'claude';
2670
3181
  const sessionId = data.sessionId;
@@ -2697,6 +3208,7 @@ function handleChatConnection(ws, request) {
2697
3208
  isProcessing: isActive
2698
3209
  });
2699
3210
  } else if (data.type === 'get-pending-permissions') {
3211
+ requireAdminChatAction('Reading pending permissions');
2700
3212
  // Return pending permission requests for a session
2701
3213
  const sessionId = data.sessionId;
2702
3214
  if (sessionId && isClaudeSDKSessionActive(sessionId)) {
@@ -2715,6 +3227,7 @@ function handleChatConnection(ws, request) {
2715
3227
  } else if (data.type === 'unwatch-project') {
2716
3228
  unsubscribeFromWorkspace(ws, data.projectName || null);
2717
3229
  } else if (data.type === 'get-active-sessions') {
3230
+ requireAdminChatAction('Listing active sessions');
2718
3231
  // Get all currently active sessions
2719
3232
  const activeSessions = {
2720
3233
  claude: getActiveClaudeSDKSessions(),
@@ -2776,11 +3289,11 @@ function handleShellConnection(ws, request) {
2776
3289
  // every provider already stores its config (~/.codex etc.).
2777
3290
  const projectPath = data.projectPath || os.homedir();
2778
3291
  const requestedProjectPath = path.resolve(projectPath);
2779
- if (!userHasProjectAccess(request.user, {
3292
+ if (!userHasProjectPathAccess(request.user, {
2780
3293
  fullPath: requestedProjectPath,
2781
3294
  path: requestedProjectPath,
2782
3295
  projectPath: requestedProjectPath,
2783
- }, 'useShell')) {
3296
+ }, requestedProjectPath, 'useShell')) {
2784
3297
  ws.send(JSON.stringify({ type: 'error', message: 'Shell access denied for this project' }));
2785
3298
  return;
2786
3299
  }
@@ -2833,7 +3346,8 @@ function handleShellConnection(ws, request) {
2833
3346
  // which collided across providers and made every disconnect →
2834
3347
  // switch-provider flow reopen the previously-running CLI.
2835
3348
  const providerSuffix = isPlainShell ? '' : `_${provider}`;
2836
- ptySessionKey = `${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
3349
+ const ownerUserId = request.user?.id ?? request.user?.userId ?? 'anonymous';
3350
+ ptySessionKey = `${ownerUserId}_${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
2837
3351
 
2838
3352
  // Kill any existing login session before starting fresh
2839
3353
  if (isLoginCommand) {
@@ -2848,7 +3362,7 @@ function handleShellConnection(ws, request) {
2848
3362
  terminatePtySession(ptySessionKey, oldSession, 'fresh plain shell session');
2849
3363
  }
2850
3364
  } else {
2851
- const killedSessions = killProviderPtySessions(projectPath, provider);
3365
+ const killedSessions = killProviderPtySessions(projectPath, provider, ownerUserId);
2852
3366
  if (killedSessions > 0) {
2853
3367
  console.log(`🧹 Fresh ${provider} session requested; terminated ${killedSessions} cached PTY session(s).`);
2854
3368
  }
@@ -3084,6 +3598,7 @@ function handleShellConnection(ws, request) {
3084
3598
  ws: ws,
3085
3599
  buffer: [],
3086
3600
  timeoutId: null,
3601
+ userId: ownerUserId,
3087
3602
  projectPath,
3088
3603
  sessionId,
3089
3604
  hermesLaunchId,
@@ -3121,6 +3636,7 @@ function handleShellConnection(ws, request) {
3121
3636
  /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
3122
3637
  '[INFO] Opening in browser: $1'
3123
3638
  );
3639
+ outputData = hideProviderApprovalChoiceLines(outputData);
3124
3640
 
3125
3641
  const emitAuthUrl = (detectedUrl, autoOpen = false) => {
3126
3642
  const normalizedUrl = normalizeDetectedUrl(detectedUrl);
@@ -3157,10 +3673,12 @@ function handleShellConnection(ws, request) {
3157
3673
  }
3158
3674
 
3159
3675
  // Send regular output
3160
- session.ws.send(JSON.stringify({
3161
- type: 'output',
3162
- data: outputData
3163
- }));
3676
+ if (outputData) {
3677
+ session.ws.send(JSON.stringify({
3678
+ type: 'output',
3679
+ data: outputData
3680
+ }));
3681
+ }
3164
3682
  }
3165
3683
  });
3166
3684