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