@pixelbyte-software/pixcode 1.51.4 → 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 (39) hide show
  1. package/dist/assets/{index-HfGHXhD6.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/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/projects.js +4 -3
  21. package/dist-server/server/routes/projects.js.map +1 -1
  22. package/dist-server/server/routes/remote.js +2 -1
  23. package/dist-server/server/routes/remote.js.map +1 -1
  24. package/dist-server/server/services/platformization.js +73 -0
  25. package/dist-server/server/services/platformization.js.map +1 -1
  26. package/package.json +1 -1
  27. package/scripts/update-git-install.mjs +15 -10
  28. package/server/cli.js +4 -4
  29. package/server/index.js +552 -34
  30. package/server/middleware/auth.js +26 -2
  31. package/server/modules/providers/provider.routes.ts +91 -53
  32. package/server/routes/commands.js +43 -32
  33. package/server/routes/live-view.js +47 -24
  34. package/server/routes/messages.js +47 -12
  35. package/server/routes/network.js +9 -8
  36. package/server/routes/projects.js +7 -6
  37. package/server/routes/remote.js +6 -5
  38. package/server/services/platformization.js +86 -13
  39. 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,15 +95,339 @@ 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, requireAdmin } from './middleware/auth.js';
101
- import { filterProjectsForUser, userHasProjectAccess } from './services/platformization.js';
101
+ import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
102
+ import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
102
103
  import { IS_PLATFORM } from './constants/config.js';
103
104
  import { getConnectableHost } from '../shared/networkHosts.js';
104
105
  import { buildDaemonCliCommand, handleDaemonCommand } from './daemon-manager.js';
105
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();
106
431
  function requireProjectAccess(capability = 'viewFiles') {
107
432
  return (req, res, next) => {
108
433
  const projectName = req.params.projectName || req.query.project || req.body?.project;
@@ -119,11 +444,11 @@ function requireProjectPathAccess(capability = 'viewFiles') {
119
444
  return (req, res, next) => {
120
445
  const projectPath = req.body?.projectPath || req.query.projectPath || os.homedir();
121
446
  const resolvedProjectPath = path.resolve(String(projectPath));
122
- if (!userHasProjectAccess(req.user, {
447
+ if (!userHasProjectPathAccess(req.user, {
123
448
  fullPath: resolvedProjectPath,
124
449
  path: resolvedProjectPath,
125
450
  projectPath: resolvedProjectPath,
126
- }, capability)) {
451
+ }, resolvedProjectPath, capability)) {
127
452
  return res.status(403).json({ error: 'Project access denied.' });
128
453
  }
129
454
  next();
@@ -413,11 +738,12 @@ function terminatePtySession(sessionKey, session, reason) {
413
738
  ptySessionsMap.delete(sessionKey);
414
739
  return true;
415
740
  }
416
- function killProviderPtySessions(projectPath, provider) {
741
+ function killProviderPtySessions(projectPath, provider, userId = null) {
417
742
  let killed = 0;
418
743
  for (const [sessionKey, session] of ptySessionsMap.entries()) {
419
744
  if (session?.projectPath === projectPath &&
420
745
  session?.provider === provider &&
746
+ (!userId || session?.userId === userId) &&
421
747
  !session?.isPlainShell) {
422
748
  killed += terminatePtySession(sessionKey, session, 'fresh provider session') ? 1 : 0;
423
749
  }
@@ -649,6 +975,24 @@ function buildProviderShellCommand(command, permissionFlags = []) {
649
975
  const flags = Array.isArray(permissionFlags) ? permissionFlags.filter(Boolean) : [];
650
976
  return flags.length > 0 ? `${command} ${flags.join(' ')}` : command;
651
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
+ }
652
996
  function resolvePublicBaseUrl(request) {
653
997
  const headers = request?.headers || {};
654
998
  const forwardedProto = String(headers['x-forwarded-proto'] || '').split(',')[0].trim();
@@ -738,15 +1082,11 @@ const wss = new WebSocketServer({
738
1082
  server,
739
1083
  verifyClient: (info) => {
740
1084
  console.log('WebSocket connection attempt to:', info.req.url);
741
- // Platform mode: always allow connection
742
- if (IS_PLATFORM) {
743
- const user = authenticateWebSocket(null); // Will return first user
744
- if (!user) {
745
- console.log('[WARN] Platform mode: No user found in database');
746
- return false;
747
- }
1085
+ const platformBypassUser = IS_PLATFORM ? authenticateWebSocket(null) : null;
1086
+ if (platformBypassUser) {
1087
+ const user = platformBypassUser;
748
1088
  info.req.user = user;
749
- 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);
750
1090
  return true;
751
1091
  }
752
1092
  // Normal mode: verify token
@@ -786,11 +1126,14 @@ app.use(express.json({
786
1126
  app.use(express.urlencoded({ limit: '50mb', extended: true }));
787
1127
  // Public health check endpoint (no authentication required)
788
1128
  app.get('/health', (req, res) => {
1129
+ const updateState = readSystemUpdateState();
789
1130
  res.json({
790
1131
  status: 'ok',
791
1132
  timestamp: new Date().toISOString(),
792
1133
  installMode,
793
- version: SERVER_VERSION
1134
+ version: SERVER_VERSION,
1135
+ pendingRestart: updateState.pendingRestart,
1136
+ lastAppliedUpdate: updateState.lastAppliedUpdate,
794
1137
  });
795
1138
  });
796
1139
  // Optional API key validation (if configured)
@@ -801,7 +1144,7 @@ app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathA
801
1144
  if (!SHELL_CLI_PROVIDERS.has(provider)) {
802
1145
  return res.status(400).json({ error: 'Unsupported provider' });
803
1146
  }
804
- 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));
805
1148
  res.json({ success: true, killedSessions });
806
1149
  });
807
1150
  app.get('/api/shell/sessions/provider-output', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
@@ -816,10 +1159,13 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
816
1159
  return res.status(400).json({ error: 'Unsupported provider' });
817
1160
  }
818
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);
819
1164
  let matchedSession = null;
820
1165
  for (const session of ptySessionsMap.values()) {
821
1166
  if (session?.provider === provider &&
822
1167
  !session?.isPlainShell &&
1168
+ (canReadAnyShellSession || session?.userId === requestUserId) &&
823
1169
  (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
824
1170
  (!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)) {
825
1171
  if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
@@ -864,12 +1210,15 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
864
1210
  return res.status(400).json({ error: 'Unsupported provider' });
865
1211
  }
866
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);
867
1215
  let matchedSession = null;
868
1216
  for (const session of ptySessionsMap.values()) {
869
1217
  if (session?.provider === provider &&
870
1218
  !session?.isPlainShell &&
871
1219
  session?.pty &&
872
1220
  session.lifecycleState === 'running' &&
1221
+ (canWriteAnyShellSession || session?.userId === requestUserId) &&
873
1222
  (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
874
1223
  (!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)) {
875
1224
  if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
@@ -962,14 +1311,14 @@ adapterRegistry.register(new QwenA2AAdapter());
962
1311
  adapterRegistry.register(new OpenCodeA2AAdapter());
963
1312
  adapterRegistry.register(new JsonEventA2AAdapter());
964
1313
  app.use('/hermes', createHermesTaskRouter());
965
- app.use('/preview', authenticateToken, createPreviewProxyRouter());
966
- app.use('/api/orchestration', authenticateToken, createOrchestrationTaskRouter());
967
- 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({
968
1317
  appRoot: APP_ROOT,
969
1318
  createHermesApiKey: getOrCreateHermesApiKey,
970
1319
  resolvePublicBaseUrl,
971
1320
  }));
972
- app.use('/api/orchestration', authenticateToken, createWorkflowRouter());
1321
+ app.use('/api/orchestration', authenticateToken, requireAdmin, createWorkflowRouter());
973
1322
  app.use('/live', createLiveViewPublicRouter());
974
1323
  // Network discovery / QR endpoints (protected)
975
1324
  app.use('/api/network', authenticateToken, networkRoutes);
@@ -1014,6 +1363,34 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
1014
1363
  // API Routes (protected)
1015
1364
  // /api/config endpoint removed - no longer needed
1016
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
+ });
1017
1394
  // System update endpoint — streams live output via Server-Sent Events so the
1018
1395
  // UI sees npm/git progress in real time instead of waiting ~2 minutes for the
1019
1396
  // buffered response.
@@ -1027,7 +1404,7 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
1027
1404
  // checkout state before pulling so source installs do not fail on local
1028
1405
  // modified files left by older releases or manual edits.
1029
1406
  // 3. fallback → `npm install -g …` (classic npm-distributed install).
1030
- app.post('/api/system/update', authenticateToken, async (req, res) => {
1407
+ app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope('system:update'), async (req, res) => {
1031
1408
  const projectRoot = APP_ROOT;
1032
1409
  console.log('Starting system update from directory:', projectRoot);
1033
1410
  const runtimeDir = process.env.PIXCODE_RUNTIME_DIR || null;
@@ -1376,7 +1753,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
1376
1753
  // Restart endpoint — exits the current process so an external wrapper
1377
1754
  // (systemd/pm2/daemon manager) can bring the server back on the new code.
1378
1755
  // Foreground installs without a wrapper will simply stop; the UI reports this.
1379
- app.post('/api/system/restart', authenticateToken, (req, res) => {
1756
+ app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
1380
1757
  res.json({
1381
1758
  success: true,
1382
1759
  version: SERVER_VERSION,
@@ -1391,7 +1768,7 @@ app.post('/api/system/restart', authenticateToken, (req, res) => {
1391
1768
  app.get('/api/projects', authenticateToken, async (req, res) => {
1392
1769
  try {
1393
1770
  const projects = await getProjects(broadcastProgress);
1394
- res.json(filterProjectsForUser(projects, req.user));
1771
+ res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
1395
1772
  }
1396
1773
  catch (error) {
1397
1774
  res.status(500).json({ error: error.message });
@@ -1401,6 +1778,9 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
1401
1778
  try {
1402
1779
  const { limit = 5, offset = 0 } = req.query;
1403
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
+ }
1404
1784
  applyCustomSessionNames(result.sessions, 'claude');
1405
1785
  res.json(result);
1406
1786
  }
@@ -1546,7 +1926,7 @@ const expandBrowsePath = (inputPath) => {
1546
1926
  return path.resolve(trimmed);
1547
1927
  };
1548
1928
  // Browse filesystem endpoint for project suggestions - uses existing getFileTree
1549
- app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
1929
+ app.get('/api/browse-filesystem', authenticateToken, requireAdmin, async (req, res) => {
1550
1930
  try {
1551
1931
  const { path: dirPath } = req.query;
1552
1932
  console.log('[API] Browse filesystem request for path:', dirPath);
@@ -1630,7 +2010,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
1630
2010
  res.status(500).json({ error: 'Failed to browse filesystem' });
1631
2011
  }
1632
2012
  });
1633
- app.post('/api/create-folder', authenticateToken, async (req, res) => {
2013
+ app.post('/api/create-folder', authenticateToken, requireAdmin, async (req, res) => {
1634
2014
  try {
1635
2015
  const { path: folderPath } = req.body;
1636
2016
  if (!folderPath) {
@@ -1694,6 +2074,9 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
1694
2074
  if (!resolved.startsWith(normalizedRoot)) {
1695
2075
  return res.status(403).json({ error: 'Path must be under project root' });
1696
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
+ }
1697
2080
  const content = await fsPromises.readFile(resolved, 'utf8');
1698
2081
  res.json({ content, path: resolved });
1699
2082
  }
@@ -1732,6 +2115,9 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
1732
2115
  if (!resolved.startsWith(normalizedRoot)) {
1733
2116
  return res.status(403).json({ error: 'Path must be under project root' });
1734
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
+ }
1735
2121
  // Check if file exists
1736
2122
  try {
1737
2123
  await fsPromises.access(resolved);
@@ -1783,6 +2169,9 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
1783
2169
  if (!resolved.startsWith(normalizedRoot)) {
1784
2170
  return res.status(403).json({ error: 'Path must be under project root' });
1785
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
+ }
1786
2175
  // Write the new content
1787
2176
  await fsPromises.writeFile(resolved, content, 'utf8');
1788
2177
  res.json({
@@ -1829,7 +2218,12 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
1829
2218
  return res.status(404).json({ error: `Project path not found: ${actualPath}` });
1830
2219
  }
1831
2220
  const files = await getFileTree(actualPath, 10, 0, true);
1832
- 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'));
1833
2227
  }
1834
2228
  catch (error) {
1835
2229
  console.error('[ERROR] File tree error:', error.message);
@@ -1909,6 +2303,9 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
1909
2303
  return res.status(403).json({ error: validation.error });
1910
2304
  }
1911
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
+ }
1912
2309
  // Check if already exists
1913
2310
  try {
1914
2311
  await fsPromises.access(resolvedPath);
@@ -1977,6 +2374,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
1977
2374
  return res.status(403).json({ error: oldValidation.error });
1978
2375
  }
1979
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
+ }
1980
2380
  // Check if old path exists
1981
2381
  try {
1982
2382
  await fsPromises.access(resolvedOldPath);
@@ -1991,6 +2391,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
1991
2391
  if (!newValidation.valid) {
1992
2392
  return res.status(403).json({ error: newValidation.error });
1993
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
+ }
1994
2397
  // Check if new path already exists
1995
2398
  try {
1996
2399
  await fsPromises.access(resolvedNewPath);
@@ -2045,6 +2448,9 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
2045
2448
  return res.status(403).json({ error: validation.error });
2046
2449
  }
2047
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
+ }
2048
2454
  // Check if path exists and get stats
2049
2455
  let stats;
2050
2456
  try {
@@ -2170,6 +2576,9 @@ const uploadFilesHandler = async (req, res) => {
2170
2576
  resolvedTargetDir = validation.resolved;
2171
2577
  console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
2172
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
+ }
2173
2582
  // Ensure target directory exists
2174
2583
  try {
2175
2584
  await fsPromises.access(resolvedTargetDir);
@@ -2194,6 +2603,10 @@ const uploadFilesHandler = async (req, res) => {
2194
2603
  await fsPromises.unlink(file.path).catch(() => { });
2195
2604
  continue;
2196
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
+ }
2197
2610
  // Ensure parent directory exists (for nested files from folder upload)
2198
2611
  const parentDir = path.dirname(destPath);
2199
2612
  try {
@@ -2313,13 +2726,35 @@ class WebSocketWriter {
2313
2726
  this.ws = ws;
2314
2727
  this.sessionId = null;
2315
2728
  this.userId = userId;
2729
+ this.provider = 'claude';
2730
+ this.projectName = null;
2731
+ this.projectPath = null;
2316
2732
  this.isWebSocketWriter = true; // Marker for transport detection
2317
2733
  }
2318
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
+ }
2319
2746
  if (this.ws.readyState === 1) { // WebSocket.OPEN
2320
2747
  this.ws.send(JSON.stringify(data));
2321
2748
  }
2322
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
+ }
2323
2758
  updateWebSocket(newRawWs) {
2324
2759
  this.ws = newRawWs;
2325
2760
  }
@@ -2339,6 +2774,43 @@ function handleChatConnection(ws, request) {
2339
2774
  connectedClients.add(ws);
2340
2775
  // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
2341
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
+ };
2342
2814
  ws.on('message', async (message) => {
2343
2815
  try {
2344
2816
  const data = JSON.parse(message);
@@ -2349,31 +2821,44 @@ function handleChatConnection(ws, request) {
2349
2821
  ? (provider, d) => console.log(`[${provider}]`, d.command?.slice(0, 60) || '[resume]', d.options?.projectPath || d.options?.cwd || '', d.options?.sessionId ? 'resume' : 'new')
2350
2822
  : () => { };
2351
2823
  if (data.type === 'claude-command') {
2824
+ assertChatCommandAccess(data);
2825
+ setWriterCommandContext('claude', data);
2352
2826
  chatDebug('claude', data);
2353
2827
  // Use Claude Agents SDK
2354
2828
  await queryClaudeSDK(data.command, data.options, writer);
2355
2829
  }
2356
2830
  else if (data.type === 'cursor-command') {
2831
+ assertChatCommandAccess(data);
2832
+ setWriterCommandContext('cursor', data);
2357
2833
  chatDebug('cursor', data);
2358
2834
  await spawnCursor(data.command, data.options, writer);
2359
2835
  }
2360
2836
  else if (data.type === 'codex-command') {
2837
+ assertChatCommandAccess(data);
2838
+ setWriterCommandContext('codex', data);
2361
2839
  chatDebug('codex', data);
2362
2840
  await queryCodex(data.command, data.options, writer);
2363
2841
  }
2364
2842
  else if (data.type === 'gemini-command') {
2843
+ assertChatCommandAccess(data);
2844
+ setWriterCommandContext('gemini', data);
2365
2845
  chatDebug('gemini', data);
2366
2846
  await spawnGemini(data.command, data.options, writer);
2367
2847
  }
2368
2848
  else if (data.type === 'qwen-command') {
2849
+ assertChatCommandAccess(data);
2850
+ setWriterCommandContext('qwen', data);
2369
2851
  chatDebug('qwen', data);
2370
2852
  await spawnQwen(data.command, data.options, writer);
2371
2853
  }
2372
2854
  else if (data.type === 'opencode-command') {
2855
+ assertChatCommandAccess(data);
2856
+ setWriterCommandContext('opencode', data);
2373
2857
  chatDebug('opencode', data);
2374
2858
  await spawnOpencode(data.command, data.options, writer);
2375
2859
  }
2376
2860
  else if (data.type === 'cursor-resume') {
2861
+ assertChatCommandAccess({ options: { cwd: data.options?.cwd } });
2377
2862
  // Backward compatibility: treat as cursor-command with resume and no prompt
2378
2863
  console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
2379
2864
  await spawnCursor('', {
@@ -2383,6 +2868,7 @@ function handleChatConnection(ws, request) {
2383
2868
  }, writer);
2384
2869
  }
2385
2870
  else if (data.type === 'abort-session') {
2871
+ requireAdminChatAction('Aborting sessions');
2386
2872
  console.log('[DEBUG] Abort session request:', data.sessionId);
2387
2873
  const provider = data.provider || 'claude';
2388
2874
  let success;
@@ -2408,6 +2894,7 @@ function handleChatConnection(ws, request) {
2408
2894
  writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
2409
2895
  }
2410
2896
  else if (data.type === 'claude-permission-response') {
2897
+ requireAdminChatAction('Resolving tool approvals');
2411
2898
  // Relay UI approval decisions back into the SDK control flow.
2412
2899
  // This does not persist permissions; it only resolves the in-flight request,
2413
2900
  // introduced so the SDK can resume once the user clicks Allow/Deny.
@@ -2421,11 +2908,13 @@ function handleChatConnection(ws, request) {
2421
2908
  }
2422
2909
  }
2423
2910
  else if (data.type === 'cursor-abort') {
2911
+ requireAdminChatAction('Aborting sessions');
2424
2912
  console.log('[DEBUG] Abort Cursor session:', data.sessionId);
2425
2913
  const success = abortCursorSession(data.sessionId);
2426
2914
  writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' }));
2427
2915
  }
2428
2916
  else if (data.type === 'check-session-status') {
2917
+ requireAdminChatAction('Checking global session status');
2429
2918
  // Check if a specific session is currently processing
2430
2919
  const provider = data.provider || 'claude';
2431
2920
  const sessionId = data.sessionId;
@@ -2462,6 +2951,7 @@ function handleChatConnection(ws, request) {
2462
2951
  });
2463
2952
  }
2464
2953
  else if (data.type === 'get-pending-permissions') {
2954
+ requireAdminChatAction('Reading pending permissions');
2465
2955
  // Return pending permission requests for a session
2466
2956
  const sessionId = data.sessionId;
2467
2957
  if (sessionId && isClaudeSDKSessionActive(sessionId)) {
@@ -2483,6 +2973,7 @@ function handleChatConnection(ws, request) {
2483
2973
  unsubscribeFromWorkspace(ws, data.projectName || null);
2484
2974
  }
2485
2975
  else if (data.type === 'get-active-sessions') {
2976
+ requireAdminChatAction('Listing active sessions');
2486
2977
  // Get all currently active sessions
2487
2978
  const activeSessions = {
2488
2979
  claude: getActiveClaudeSDKSessions(),
@@ -2541,11 +3032,11 @@ function handleShellConnection(ws, request) {
2541
3032
  // every provider already stores its config (~/.codex etc.).
2542
3033
  const projectPath = data.projectPath || os.homedir();
2543
3034
  const requestedProjectPath = path.resolve(projectPath);
2544
- if (!userHasProjectAccess(request.user, {
3035
+ if (!userHasProjectPathAccess(request.user, {
2545
3036
  fullPath: requestedProjectPath,
2546
3037
  path: requestedProjectPath,
2547
3038
  projectPath: requestedProjectPath,
2548
- }, 'useShell')) {
3039
+ }, requestedProjectPath, 'useShell')) {
2549
3040
  ws.send(JSON.stringify({ type: 'error', message: 'Shell access denied for this project' }));
2550
3041
  return;
2551
3042
  }
@@ -2594,7 +3085,8 @@ function handleShellConnection(ws, request) {
2594
3085
  // which collided across providers and made every disconnect →
2595
3086
  // switch-provider flow reopen the previously-running CLI.
2596
3087
  const providerSuffix = isPlainShell ? '' : `_${provider}`;
2597
- ptySessionKey = `${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
3088
+ const ownerUserId = request.user?.id ?? request.user?.userId ?? 'anonymous';
3089
+ ptySessionKey = `${ownerUserId}_${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
2598
3090
  // Kill any existing login session before starting fresh
2599
3091
  if (isLoginCommand) {
2600
3092
  const oldSession = ptySessionsMap.get(ptySessionKey);
@@ -2610,7 +3102,7 @@ function handleShellConnection(ws, request) {
2610
3102
  }
2611
3103
  }
2612
3104
  else {
2613
- const killedSessions = killProviderPtySessions(projectPath, provider);
3105
+ const killedSessions = killProviderPtySessions(projectPath, provider, ownerUserId);
2614
3106
  if (killedSessions > 0) {
2615
3107
  console.log(`🧹 Fresh ${provider} session requested; terminated ${killedSessions} cached PTY session(s).`);
2616
3108
  }
@@ -2846,6 +3338,7 @@ function handleShellConnection(ws, request) {
2846
3338
  ws: ws,
2847
3339
  buffer: [],
2848
3340
  timeoutId: null,
3341
+ userId: ownerUserId,
2849
3342
  projectPath,
2850
3343
  sessionId,
2851
3344
  hermesLaunchId,
@@ -2876,6 +3369,7 @@ function handleShellConnection(ws, request) {
2876
3369
  const cleanChunk = stripAnsiSequences(data);
2877
3370
  urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
2878
3371
  outputData = outputData.replace(/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g, '[INFO] Opening in browser: $1');
3372
+ outputData = hideProviderApprovalChoiceLines(outputData);
2879
3373
  const emitAuthUrl = (detectedUrl, autoOpen = false) => {
2880
3374
  const normalizedUrl = normalizeDetectedUrl(detectedUrl);
2881
3375
  if (!normalizedUrl)
@@ -2901,10 +3395,12 @@ function handleShellConnection(ws, request) {
2901
3395
  emitAuthUrl(bestUrl, true);
2902
3396
  }
2903
3397
  // Send regular output
2904
- session.ws.send(JSON.stringify({
2905
- type: 'output',
2906
- data: outputData
2907
- }));
3398
+ if (outputData) {
3399
+ session.ws.send(JSON.stringify({
3400
+ type: 'output',
3401
+ data: outputData
3402
+ }));
3403
+ }
2908
3404
  }
2909
3405
  });
2910
3406
  // Handle process exit