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