@pixelbyte-software/pixcode 1.51.4 → 1.51.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-HfGHXhD6.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/index.js +530 -34
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/middleware/auth.js +20 -3
- 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/commands.js +20 -9
- package/dist-server/server/routes/commands.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/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 +73 -0
- 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/index.js +552 -34
- package/server/middleware/auth.js +26 -2
- package/server/modules/providers/provider.routes.ts +91 -53
- package/server/routes/commands.js +43 -32
- 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/projects.js +7 -6
- package/server/routes/remote.js +6 -5
- package/server/services/platformization.js +86 -13
- package/dist/assets/index-B9N-gfOQ.css +0 -32
|
@@ -7,6 +7,7 @@ import path from 'path';
|
|
|
7
7
|
import os from 'os';
|
|
8
8
|
import http from 'http';
|
|
9
9
|
import net from 'node:net';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
10
11
|
import { createRequire } from 'node:module';
|
|
11
12
|
import { spawn } from 'child_process';
|
|
12
13
|
import express from 'express';
|
|
@@ -94,15 +95,339 @@ import { applyAllStoredCredentialsToEnv, } from './services/provider-credentials
|
|
|
94
95
|
import { primeCliBinPath } from './services/install-jobs.js';
|
|
95
96
|
import { buildHermesPathEnv, primeHermesPath } from './services/hermes-install-jobs.js';
|
|
96
97
|
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
|
97
|
-
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, apiKeysDb } from './database/db.js';
|
|
98
|
+
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, apiKeysDb, appConfigDb } from './database/db.js';
|
|
98
99
|
import { setNotificationWebSocketServer } from './services/notification-orchestrator.js';
|
|
99
100
|
import { configureWebPush } from './services/vapid-keys.js';
|
|
100
|
-
import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin } from './middleware/auth.js';
|
|
101
|
-
import { filterProjectsForUser, userHasProjectAccess } from './services/platformization.js';
|
|
101
|
+
import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
|
|
102
|
+
import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
|
|
102
103
|
import { IS_PLATFORM } from './constants/config.js';
|
|
103
104
|
import { getConnectableHost } from '../shared/networkHosts.js';
|
|
104
105
|
import { buildDaemonCliCommand, handleDaemonCommand } from './daemon-manager.js';
|
|
105
106
|
const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode'];
|
|
107
|
+
const SYSTEM_UPDATE_STATE_KEY = 'system_update_state';
|
|
108
|
+
const SESSION_OWNERSHIP_KEY = 'session_ownership';
|
|
109
|
+
const SYSTEM_UPDATE_LOG_LIMIT = 600;
|
|
110
|
+
const updateJobs = new Map();
|
|
111
|
+
function parseStoredJson(value, fallback = {}) {
|
|
112
|
+
if (!value)
|
|
113
|
+
return fallback;
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(value);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return fallback;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function readSystemUpdateState() {
|
|
122
|
+
return parseStoredJson(appConfigDb.get(SYSTEM_UPDATE_STATE_KEY), {
|
|
123
|
+
pendingRestart: null,
|
|
124
|
+
lastAppliedUpdate: null,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function writeSystemUpdateState(state) {
|
|
128
|
+
appConfigDb.set(SYSTEM_UPDATE_STATE_KEY, JSON.stringify({
|
|
129
|
+
pendingRestart: state?.pendingRestart || null,
|
|
130
|
+
lastAppliedUpdate: state?.lastAppliedUpdate || null,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
function readSessionOwnership() {
|
|
134
|
+
return parseStoredJson(appConfigDb.get(SESSION_OWNERSHIP_KEY), {});
|
|
135
|
+
}
|
|
136
|
+
function writeSessionOwnership(ownership) {
|
|
137
|
+
appConfigDb.set(SESSION_OWNERSHIP_KEY, JSON.stringify(ownership || {}));
|
|
138
|
+
}
|
|
139
|
+
function sessionOwnershipKey(provider, sessionId) {
|
|
140
|
+
return `${provider || 'claude'}:${sessionId}`;
|
|
141
|
+
}
|
|
142
|
+
function recordSessionOwnership({ provider, sessionId, userId, projectName, projectPath }) {
|
|
143
|
+
if (!sessionId || !userId)
|
|
144
|
+
return;
|
|
145
|
+
const ownership = readSessionOwnership();
|
|
146
|
+
const key = sessionOwnershipKey(provider, sessionId);
|
|
147
|
+
if (ownership[key]?.userId)
|
|
148
|
+
return;
|
|
149
|
+
ownership[key] = {
|
|
150
|
+
provider: provider || 'claude',
|
|
151
|
+
sessionId,
|
|
152
|
+
userId,
|
|
153
|
+
projectName: projectName || null,
|
|
154
|
+
projectPath: projectPath || null,
|
|
155
|
+
createdAt: new Date().toISOString(),
|
|
156
|
+
};
|
|
157
|
+
writeSessionOwnership(ownership);
|
|
158
|
+
}
|
|
159
|
+
function canUserSeeSession(user, session, provider) {
|
|
160
|
+
if (['admin', 'owner'].includes(user?.role))
|
|
161
|
+
return true;
|
|
162
|
+
const sessionId = session?.id || session?.sessionId;
|
|
163
|
+
if (!sessionId)
|
|
164
|
+
return false;
|
|
165
|
+
const owner = readSessionOwnership()[sessionOwnershipKey(provider, sessionId)];
|
|
166
|
+
return Boolean(owner?.userId && Number(owner.userId) === Number(user?.id ?? user?.userId));
|
|
167
|
+
}
|
|
168
|
+
function filterProjectSessionsForUser(project, user) {
|
|
169
|
+
if (['admin', 'owner'].includes(user?.role))
|
|
170
|
+
return project;
|
|
171
|
+
return {
|
|
172
|
+
...project,
|
|
173
|
+
sessions: (project.sessions || []).filter((session) => canUserSeeSession(user, session, 'claude')),
|
|
174
|
+
cursorSessions: (project.cursorSessions || []).filter((session) => canUserSeeSession(user, session, 'cursor')),
|
|
175
|
+
codexSessions: (project.codexSessions || []).filter((session) => canUserSeeSession(user, session, 'codex')),
|
|
176
|
+
geminiSessions: (project.geminiSessions || []).filter((session) => canUserSeeSession(user, session, 'gemini')),
|
|
177
|
+
qwenSessions: (project.qwenSessions || []).filter((session) => canUserSeeSession(user, session, 'qwen')),
|
|
178
|
+
opencodeSessions: (project.opencodeSessions || []).filter((session) => canUserSeeSession(user, session, 'opencode')),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function reconcileAppliedUpdateStateOnBoot() {
|
|
182
|
+
const state = readSystemUpdateState();
|
|
183
|
+
const pending = state.pendingRestart;
|
|
184
|
+
if (!pending?.toVersion || pending.toVersion !== SERVER_VERSION) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
writeSystemUpdateState({
|
|
188
|
+
pendingRestart: null,
|
|
189
|
+
lastAppliedUpdate: {
|
|
190
|
+
...pending,
|
|
191
|
+
appliedAt: new Date().toISOString(),
|
|
192
|
+
currentVersion: SERVER_VERSION,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function appendUpdateJobLog(job, stream, chunk) {
|
|
197
|
+
const entry = {
|
|
198
|
+
stream,
|
|
199
|
+
chunk: String(chunk || ''),
|
|
200
|
+
timestamp: new Date().toISOString(),
|
|
201
|
+
};
|
|
202
|
+
job.logs.push(entry);
|
|
203
|
+
if (job.logs.length > SYSTEM_UPDATE_LOG_LIMIT) {
|
|
204
|
+
job.logs.splice(0, job.logs.length - SYSTEM_UPDATE_LOG_LIMIT);
|
|
205
|
+
}
|
|
206
|
+
job.updatedAt = entry.timestamp;
|
|
207
|
+
}
|
|
208
|
+
function snapshotUpdateJob(job) {
|
|
209
|
+
if (!job)
|
|
210
|
+
return null;
|
|
211
|
+
return {
|
|
212
|
+
id: job.id,
|
|
213
|
+
status: job.status,
|
|
214
|
+
startedAt: job.startedAt,
|
|
215
|
+
updatedAt: job.updatedAt,
|
|
216
|
+
completedAt: job.completedAt || null,
|
|
217
|
+
fromVersion: job.fromVersion,
|
|
218
|
+
toVersion: job.toVersion || null,
|
|
219
|
+
installMode: job.installMode,
|
|
220
|
+
runtimeDir: job.runtimeDir || null,
|
|
221
|
+
alreadyLatest: Boolean(job.alreadyLatest),
|
|
222
|
+
pendingRestart: Boolean(job.pendingRestart),
|
|
223
|
+
error: job.error || null,
|
|
224
|
+
logs: job.logs,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function getActiveUpdateJob() {
|
|
228
|
+
for (const job of updateJobs.values()) {
|
|
229
|
+
if (job.status === 'running' || job.status === 'queued') {
|
|
230
|
+
return job;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
async function readLatestPixcodePackageMetadata() {
|
|
236
|
+
const registryRes = await fetch('https://registry.npmjs.org/@pixelbyte-software/pixcode');
|
|
237
|
+
if (!registryRes.ok)
|
|
238
|
+
throw new Error(`Registry returned HTTP ${registryRes.status}`);
|
|
239
|
+
const metadata = await registryRes.json();
|
|
240
|
+
const latestVersion = metadata['dist-tags']?.latest;
|
|
241
|
+
const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
|
|
242
|
+
return {
|
|
243
|
+
latestVersion,
|
|
244
|
+
tarballUrl: latestEntry?.dist?.tarball || null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl) {
|
|
248
|
+
appendUpdateJobLog(job, 'meta', `Update mode: runtime-dir\nRuntime: ${runtimeDir}\n`);
|
|
249
|
+
appendUpdateJobLog(job, 'meta', `Downloading ${tarballUrl}\n`);
|
|
250
|
+
const tarballRes = await fetch(tarballUrl);
|
|
251
|
+
if (!tarballRes.ok || !tarballRes.body) {
|
|
252
|
+
throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
|
|
253
|
+
}
|
|
254
|
+
const stagingDir = path.join(runtimeDir, '.staging');
|
|
255
|
+
const backupDir = path.join(runtimeDir, '.previous');
|
|
256
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
257
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
258
|
+
const { Readable } = await import('node:stream');
|
|
259
|
+
const tarModule = await import('tar');
|
|
260
|
+
const tarExtract = tarModule.x || tarModule.default?.x;
|
|
261
|
+
if (!tarExtract)
|
|
262
|
+
throw new Error('tar extractor not available');
|
|
263
|
+
await new Promise((resolve, reject) => {
|
|
264
|
+
const webStream = tarballRes.body;
|
|
265
|
+
const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
|
|
266
|
+
? Readable.fromWeb(webStream)
|
|
267
|
+
: webStream;
|
|
268
|
+
const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
|
|
269
|
+
nodeStream.pipe(extractor);
|
|
270
|
+
extractor.on('finish', resolve);
|
|
271
|
+
extractor.on('error', reject);
|
|
272
|
+
nodeStream.on('error', reject);
|
|
273
|
+
});
|
|
274
|
+
appendUpdateJobLog(job, 'meta', 'Staging runtime update for next restart...\n');
|
|
275
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
276
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
277
|
+
for (const entry of fs.readdirSync(stagingDir)) {
|
|
278
|
+
const src = path.join(stagingDir, entry);
|
|
279
|
+
const dst = path.join(runtimeDir, entry);
|
|
280
|
+
if (fs.existsSync(dst)) {
|
|
281
|
+
fs.renameSync(dst, path.join(backupDir, entry));
|
|
282
|
+
}
|
|
283
|
+
fs.renameSync(src, dst);
|
|
284
|
+
}
|
|
285
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
286
|
+
const depsChanged = (() => {
|
|
287
|
+
try {
|
|
288
|
+
const prevPkg = JSON.parse(fs.readFileSync(path.join(backupDir, 'package.json'), 'utf8'));
|
|
289
|
+
const nextPkg = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'package.json'), 'utf8'));
|
|
290
|
+
return JSON.stringify(prevPkg.dependencies || {}) !== JSON.stringify(nextPkg.dependencies || {});
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
})();
|
|
296
|
+
if (!depsChanged)
|
|
297
|
+
return latestVersion;
|
|
298
|
+
appendUpdateJobLog(job, 'meta', 'Reconciling node_modules with new package.json...\n');
|
|
299
|
+
await new Promise((resolve, reject) => {
|
|
300
|
+
const npmChild = spawn('npm', ['install', '--production', '--no-audit', '--no-fund', '--no-save'], {
|
|
301
|
+
cwd: runtimeDir,
|
|
302
|
+
env: process.env,
|
|
303
|
+
shell: true,
|
|
304
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
305
|
+
});
|
|
306
|
+
npmChild.stdout?.on('data', (chunk) => appendUpdateJobLog(job, 'stdout', chunk.toString()));
|
|
307
|
+
npmChild.stderr?.on('data', (chunk) => appendUpdateJobLog(job, 'stderr', chunk.toString()));
|
|
308
|
+
npmChild.on('error', reject);
|
|
309
|
+
npmChild.on('close', (code) => {
|
|
310
|
+
if (code === 0)
|
|
311
|
+
resolve();
|
|
312
|
+
else
|
|
313
|
+
reject(new Error(`npm install exited with code ${code}`));
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
return latestVersion;
|
|
317
|
+
}
|
|
318
|
+
async function runCommandUpdateJob(job, updateCommand, updateCwd) {
|
|
319
|
+
appendUpdateJobLog(job, 'meta', `Running: ${updateCommand}\n`);
|
|
320
|
+
await new Promise((resolve, reject) => {
|
|
321
|
+
const child = spawn(updateCommand, {
|
|
322
|
+
cwd: updateCwd,
|
|
323
|
+
env: process.env,
|
|
324
|
+
shell: true,
|
|
325
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
326
|
+
});
|
|
327
|
+
child.stdout?.on('data', (data) => appendUpdateJobLog(job, 'stdout', data.toString()));
|
|
328
|
+
child.stderr?.on('data', (data) => appendUpdateJobLog(job, 'stderr', data.toString()));
|
|
329
|
+
child.on('error', reject);
|
|
330
|
+
child.on('close', (code) => {
|
|
331
|
+
if (code === 0)
|
|
332
|
+
resolve();
|
|
333
|
+
else
|
|
334
|
+
reject(new Error(`Update command exited with code ${code}`));
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
function readCurrentPackageVersion() {
|
|
339
|
+
try {
|
|
340
|
+
const pkgRaw = fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8');
|
|
341
|
+
return JSON.parse(pkgRaw).version || SERVER_VERSION;
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return SERVER_VERSION;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function createSystemUpdateJob(actorUser) {
|
|
348
|
+
const activeJob = getActiveUpdateJob();
|
|
349
|
+
if (activeJob)
|
|
350
|
+
return activeJob;
|
|
351
|
+
const job = {
|
|
352
|
+
id: crypto.randomUUID(),
|
|
353
|
+
status: 'queued',
|
|
354
|
+
startedAt: new Date().toISOString(),
|
|
355
|
+
updatedAt: new Date().toISOString(),
|
|
356
|
+
completedAt: null,
|
|
357
|
+
fromVersion: SERVER_VERSION,
|
|
358
|
+
toVersion: null,
|
|
359
|
+
installMode,
|
|
360
|
+
runtimeDir: process.env.PIXCODE_RUNTIME_DIR || null,
|
|
361
|
+
actorUserId: actorUser?.id ?? actorUser?.userId ?? null,
|
|
362
|
+
logs: [],
|
|
363
|
+
alreadyLatest: false,
|
|
364
|
+
pendingRestart: false,
|
|
365
|
+
error: null,
|
|
366
|
+
};
|
|
367
|
+
updateJobs.set(job.id, job);
|
|
368
|
+
void (async () => {
|
|
369
|
+
job.status = 'running';
|
|
370
|
+
appendUpdateJobLog(job, 'meta', `Background update job started at ${job.startedAt}\n`);
|
|
371
|
+
try {
|
|
372
|
+
const runtimeDir = job.runtimeDir;
|
|
373
|
+
const gitUpdateScript = path.join(APP_ROOT, 'scripts', 'update-git-install.mjs');
|
|
374
|
+
const latest = await readLatestPixcodePackageMetadata().catch((error) => {
|
|
375
|
+
appendUpdateJobLog(job, 'stderr', `Registry precheck failed: ${error.message}\n`);
|
|
376
|
+
return { latestVersion: null, tarballUrl: null };
|
|
377
|
+
});
|
|
378
|
+
job.toVersion = latest.latestVersion || null;
|
|
379
|
+
if (!IS_PLATFORM && installMode === 'npm' && latest.latestVersion && latest.latestVersion === SERVER_VERSION) {
|
|
380
|
+
job.status = 'completed';
|
|
381
|
+
job.alreadyLatest = true;
|
|
382
|
+
job.completedAt = new Date().toISOString();
|
|
383
|
+
appendUpdateJobLog(job, 'meta', `Already on ${SERVER_VERSION}.\n`);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (runtimeDir) {
|
|
387
|
+
if (!latest.latestVersion || !latest.tarballUrl) {
|
|
388
|
+
throw new Error('Registry response missing latest version or tarball URL.');
|
|
389
|
+
}
|
|
390
|
+
job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, latest.latestVersion, latest.tarballUrl);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
const updateCommand = IS_PLATFORM
|
|
394
|
+
? 'npm run update:platform'
|
|
395
|
+
: installMode === 'git'
|
|
396
|
+
? `${JSON.stringify(process.execPath)} ${JSON.stringify(gitUpdateScript)}`
|
|
397
|
+
: 'npm install -g @pixelbyte-software/pixcode@latest';
|
|
398
|
+
const updateCwd = IS_PLATFORM || installMode === 'git' ? APP_ROOT : os.homedir();
|
|
399
|
+
await runCommandUpdateJob(job, updateCommand, updateCwd);
|
|
400
|
+
if (installMode === 'git') {
|
|
401
|
+
job.toVersion = readCurrentPackageVersion();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
job.status = 'completed';
|
|
405
|
+
job.completedAt = new Date().toISOString();
|
|
406
|
+
job.pendingRestart = true;
|
|
407
|
+
const state = readSystemUpdateState();
|
|
408
|
+
writeSystemUpdateState({
|
|
409
|
+
...state,
|
|
410
|
+
pendingRestart: {
|
|
411
|
+
jobId: job.id,
|
|
412
|
+
fromVersion: job.fromVersion,
|
|
413
|
+
toVersion: job.toVersion || latest.latestVersion || SERVER_VERSION,
|
|
414
|
+
installMode: job.installMode,
|
|
415
|
+
completedAt: job.completedAt,
|
|
416
|
+
logs: job.logs.slice(-80),
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
appendUpdateJobLog(job, 'meta', 'Update is ready. Restart when convenient to apply it.\n');
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
job.status = 'failed';
|
|
423
|
+
job.completedAt = new Date().toISOString();
|
|
424
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
425
|
+
appendUpdateJobLog(job, 'stderr', `Update failed: ${job.error}\n`);
|
|
426
|
+
}
|
|
427
|
+
})();
|
|
428
|
+
return job;
|
|
429
|
+
}
|
|
430
|
+
reconcileAppliedUpdateStateOnBoot();
|
|
106
431
|
function requireProjectAccess(capability = 'viewFiles') {
|
|
107
432
|
return (req, res, next) => {
|
|
108
433
|
const projectName = req.params.projectName || req.query.project || req.body?.project;
|
|
@@ -119,11 +444,11 @@ function requireProjectPathAccess(capability = 'viewFiles') {
|
|
|
119
444
|
return (req, res, next) => {
|
|
120
445
|
const projectPath = req.body?.projectPath || req.query.projectPath || os.homedir();
|
|
121
446
|
const resolvedProjectPath = path.resolve(String(projectPath));
|
|
122
|
-
if (!
|
|
447
|
+
if (!userHasProjectPathAccess(req.user, {
|
|
123
448
|
fullPath: resolvedProjectPath,
|
|
124
449
|
path: resolvedProjectPath,
|
|
125
450
|
projectPath: resolvedProjectPath,
|
|
126
|
-
}, capability)) {
|
|
451
|
+
}, resolvedProjectPath, capability)) {
|
|
127
452
|
return res.status(403).json({ error: 'Project access denied.' });
|
|
128
453
|
}
|
|
129
454
|
next();
|
|
@@ -413,11 +738,12 @@ function terminatePtySession(sessionKey, session, reason) {
|
|
|
413
738
|
ptySessionsMap.delete(sessionKey);
|
|
414
739
|
return true;
|
|
415
740
|
}
|
|
416
|
-
function killProviderPtySessions(projectPath, provider) {
|
|
741
|
+
function killProviderPtySessions(projectPath, provider, userId = null) {
|
|
417
742
|
let killed = 0;
|
|
418
743
|
for (const [sessionKey, session] of ptySessionsMap.entries()) {
|
|
419
744
|
if (session?.projectPath === projectPath &&
|
|
420
745
|
session?.provider === provider &&
|
|
746
|
+
(!userId || session?.userId === userId) &&
|
|
421
747
|
!session?.isPlainShell) {
|
|
422
748
|
killed += terminatePtySession(sessionKey, session, 'fresh provider session') ? 1 : 0;
|
|
423
749
|
}
|
|
@@ -649,6 +975,24 @@ function buildProviderShellCommand(command, permissionFlags = []) {
|
|
|
649
975
|
const flags = Array.isArray(permissionFlags) ? permissionFlags.filter(Boolean) : [];
|
|
650
976
|
return flags.length > 0 ? `${command} ${flags.join(' ')}` : command;
|
|
651
977
|
}
|
|
978
|
+
function hideProviderApprovalChoiceLines(output) {
|
|
979
|
+
const approvalChoicePattern = /(?:^|\s)(?:[0-9]+[.)]\s*)?(?:yes,?\s+proceed|yes,?\s+and\s+don['’]?t|no,?\s+and\s+(?:tell|keep|cancel)|no,?\s+do\s+not)/iu;
|
|
980
|
+
return String(output || '')
|
|
981
|
+
.split(/(\r\n|\n|\r)/u)
|
|
982
|
+
.reduce((parts, part, index, chunks) => {
|
|
983
|
+
if (part === '\r\n' || part === '\n' || part === '\r') {
|
|
984
|
+
const previousWasHidden = chunks[index - 1] && approvalChoicePattern.test(stripAnsiSequences(chunks[index - 1]));
|
|
985
|
+
if (!previousWasHidden)
|
|
986
|
+
parts.push(part);
|
|
987
|
+
return parts;
|
|
988
|
+
}
|
|
989
|
+
if (!approvalChoicePattern.test(stripAnsiSequences(part))) {
|
|
990
|
+
parts.push(part);
|
|
991
|
+
}
|
|
992
|
+
return parts;
|
|
993
|
+
}, [])
|
|
994
|
+
.join('');
|
|
995
|
+
}
|
|
652
996
|
function resolvePublicBaseUrl(request) {
|
|
653
997
|
const headers = request?.headers || {};
|
|
654
998
|
const forwardedProto = String(headers['x-forwarded-proto'] || '').split(',')[0].trim();
|
|
@@ -738,15 +1082,11 @@ const wss = new WebSocketServer({
|
|
|
738
1082
|
server,
|
|
739
1083
|
verifyClient: (info) => {
|
|
740
1084
|
console.log('WebSocket connection attempt to:', info.req.url);
|
|
741
|
-
|
|
742
|
-
if (
|
|
743
|
-
const user =
|
|
744
|
-
if (!user) {
|
|
745
|
-
console.log('[WARN] Platform mode: No user found in database');
|
|
746
|
-
return false;
|
|
747
|
-
}
|
|
1085
|
+
const platformBypassUser = IS_PLATFORM ? authenticateWebSocket(null) : null;
|
|
1086
|
+
if (platformBypassUser) {
|
|
1087
|
+
const user = platformBypassUser;
|
|
748
1088
|
info.req.user = user;
|
|
749
|
-
console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
|
|
1089
|
+
console.log('[OK] Platform mode WebSocket authenticated via explicit bypass for user:', user.username);
|
|
750
1090
|
return true;
|
|
751
1091
|
}
|
|
752
1092
|
// Normal mode: verify token
|
|
@@ -786,11 +1126,14 @@ app.use(express.json({
|
|
|
786
1126
|
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
|
787
1127
|
// Public health check endpoint (no authentication required)
|
|
788
1128
|
app.get('/health', (req, res) => {
|
|
1129
|
+
const updateState = readSystemUpdateState();
|
|
789
1130
|
res.json({
|
|
790
1131
|
status: 'ok',
|
|
791
1132
|
timestamp: new Date().toISOString(),
|
|
792
1133
|
installMode,
|
|
793
|
-
version: SERVER_VERSION
|
|
1134
|
+
version: SERVER_VERSION,
|
|
1135
|
+
pendingRestart: updateState.pendingRestart,
|
|
1136
|
+
lastAppliedUpdate: updateState.lastAppliedUpdate,
|
|
794
1137
|
});
|
|
795
1138
|
});
|
|
796
1139
|
// Optional API key validation (if configured)
|
|
@@ -801,7 +1144,7 @@ app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathA
|
|
|
801
1144
|
if (!SHELL_CLI_PROVIDERS.has(provider)) {
|
|
802
1145
|
return res.status(400).json({ error: 'Unsupported provider' });
|
|
803
1146
|
}
|
|
804
|
-
const killedSessions = killProviderPtySessions(projectPath, provider);
|
|
1147
|
+
const killedSessions = killProviderPtySessions(projectPath, provider, ['admin', 'owner'].includes(req.user?.role) ? null : (req.user?.id ?? req.user?.userId ?? null));
|
|
805
1148
|
res.json({ success: true, killedSessions });
|
|
806
1149
|
});
|
|
807
1150
|
app.get('/api/shell/sessions/provider-output', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
|
|
@@ -816,10 +1159,13 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
|
|
|
816
1159
|
return res.status(400).json({ error: 'Unsupported provider' });
|
|
817
1160
|
}
|
|
818
1161
|
const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
|
|
1162
|
+
const requestUserId = req.user?.id ?? req.user?.userId ?? null;
|
|
1163
|
+
const canReadAnyShellSession = ['admin', 'owner'].includes(req.user?.role);
|
|
819
1164
|
let matchedSession = null;
|
|
820
1165
|
for (const session of ptySessionsMap.values()) {
|
|
821
1166
|
if (session?.provider === provider &&
|
|
822
1167
|
!session?.isPlainShell &&
|
|
1168
|
+
(canReadAnyShellSession || session?.userId === requestUserId) &&
|
|
823
1169
|
(!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
|
|
824
1170
|
(!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)) {
|
|
825
1171
|
if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
|
|
@@ -864,12 +1210,15 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
|
|
|
864
1210
|
return res.status(400).json({ error: 'Unsupported provider' });
|
|
865
1211
|
}
|
|
866
1212
|
const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
|
|
1213
|
+
const requestUserId = req.user?.id ?? req.user?.userId ?? null;
|
|
1214
|
+
const canWriteAnyShellSession = ['admin', 'owner'].includes(req.user?.role);
|
|
867
1215
|
let matchedSession = null;
|
|
868
1216
|
for (const session of ptySessionsMap.values()) {
|
|
869
1217
|
if (session?.provider === provider &&
|
|
870
1218
|
!session?.isPlainShell &&
|
|
871
1219
|
session?.pty &&
|
|
872
1220
|
session.lifecycleState === 'running' &&
|
|
1221
|
+
(canWriteAnyShellSession || session?.userId === requestUserId) &&
|
|
873
1222
|
(!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
|
|
874
1223
|
(!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)) {
|
|
875
1224
|
if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
|
|
@@ -962,14 +1311,14 @@ adapterRegistry.register(new QwenA2AAdapter());
|
|
|
962
1311
|
adapterRegistry.register(new OpenCodeA2AAdapter());
|
|
963
1312
|
adapterRegistry.register(new JsonEventA2AAdapter());
|
|
964
1313
|
app.use('/hermes', createHermesTaskRouter());
|
|
965
|
-
app.use('/preview', authenticateToken, createPreviewProxyRouter());
|
|
966
|
-
app.use('/api/orchestration', authenticateToken, createOrchestrationTaskRouter());
|
|
967
|
-
app.use('/api/orchestration/hermes', authenticateToken, createHermesRouter({
|
|
1314
|
+
app.use('/preview', authenticateToken, requireAdmin, createPreviewProxyRouter());
|
|
1315
|
+
app.use('/api/orchestration', authenticateToken, requireAdmin, createOrchestrationTaskRouter());
|
|
1316
|
+
app.use('/api/orchestration/hermes', authenticateToken, requireAdmin, createHermesRouter({
|
|
968
1317
|
appRoot: APP_ROOT,
|
|
969
1318
|
createHermesApiKey: getOrCreateHermesApiKey,
|
|
970
1319
|
resolvePublicBaseUrl,
|
|
971
1320
|
}));
|
|
972
|
-
app.use('/api/orchestration', authenticateToken, createWorkflowRouter());
|
|
1321
|
+
app.use('/api/orchestration', authenticateToken, requireAdmin, createWorkflowRouter());
|
|
973
1322
|
app.use('/live', createLiveViewPublicRouter());
|
|
974
1323
|
// Network discovery / QR endpoints (protected)
|
|
975
1324
|
app.use('/api/network', authenticateToken, networkRoutes);
|
|
@@ -1014,6 +1363,34 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
|
|
|
1014
1363
|
// API Routes (protected)
|
|
1015
1364
|
// /api/config endpoint removed - no longer needed
|
|
1016
1365
|
// Frontend now uses window.location for WebSocket URLs
|
|
1366
|
+
app.get('/api/system/update-state', authenticateToken, (req, res) => {
|
|
1367
|
+
res.json({
|
|
1368
|
+
success: true,
|
|
1369
|
+
state: readSystemUpdateState(),
|
|
1370
|
+
activeJob: snapshotUpdateJob(getActiveUpdateJob()),
|
|
1371
|
+
currentVersion: SERVER_VERSION,
|
|
1372
|
+
installMode,
|
|
1373
|
+
capabilities: {
|
|
1374
|
+
canBackgroundUpdate: true,
|
|
1375
|
+
canRestart: true,
|
|
1376
|
+
startupUpdateDefault: false,
|
|
1377
|
+
},
|
|
1378
|
+
});
|
|
1379
|
+
});
|
|
1380
|
+
app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
|
|
1381
|
+
const job = createSystemUpdateJob(req.user);
|
|
1382
|
+
res.status(job.status === 'queued' ? 202 : 200).json({
|
|
1383
|
+
success: true,
|
|
1384
|
+
job: snapshotUpdateJob(job),
|
|
1385
|
+
});
|
|
1386
|
+
});
|
|
1387
|
+
app.get('/api/system/update-jobs/:jobId', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
|
|
1388
|
+
const job = updateJobs.get(req.params.jobId);
|
|
1389
|
+
if (!job) {
|
|
1390
|
+
return res.status(404).json({ success: false, error: 'Update job not found' });
|
|
1391
|
+
}
|
|
1392
|
+
res.json({ success: true, job: snapshotUpdateJob(job) });
|
|
1393
|
+
});
|
|
1017
1394
|
// System update endpoint — streams live output via Server-Sent Events so the
|
|
1018
1395
|
// UI sees npm/git progress in real time instead of waiting ~2 minutes for the
|
|
1019
1396
|
// buffered response.
|
|
@@ -1027,7 +1404,7 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
|
|
|
1027
1404
|
// checkout state before pulling so source installs do not fail on local
|
|
1028
1405
|
// modified files left by older releases or manual edits.
|
|
1029
1406
|
// 3. fallback → `npm install -g …` (classic npm-distributed install).
|
|
1030
|
-
app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|
1407
|
+
app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope('system:update'), async (req, res) => {
|
|
1031
1408
|
const projectRoot = APP_ROOT;
|
|
1032
1409
|
console.log('Starting system update from directory:', projectRoot);
|
|
1033
1410
|
const runtimeDir = process.env.PIXCODE_RUNTIME_DIR || null;
|
|
@@ -1376,7 +1753,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|
|
1376
1753
|
// Restart endpoint — exits the current process so an external wrapper
|
|
1377
1754
|
// (systemd/pm2/daemon manager) can bring the server back on the new code.
|
|
1378
1755
|
// Foreground installs without a wrapper will simply stop; the UI reports this.
|
|
1379
|
-
app.post('/api/system/restart', authenticateToken, (req, res) => {
|
|
1756
|
+
app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
|
|
1380
1757
|
res.json({
|
|
1381
1758
|
success: true,
|
|
1382
1759
|
version: SERVER_VERSION,
|
|
@@ -1391,7 +1768,7 @@ app.post('/api/system/restart', authenticateToken, (req, res) => {
|
|
|
1391
1768
|
app.get('/api/projects', authenticateToken, async (req, res) => {
|
|
1392
1769
|
try {
|
|
1393
1770
|
const projects = await getProjects(broadcastProgress);
|
|
1394
|
-
res.json(filterProjectsForUser(projects, req.user));
|
|
1771
|
+
res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
|
|
1395
1772
|
}
|
|
1396
1773
|
catch (error) {
|
|
1397
1774
|
res.status(500).json({ error: error.message });
|
|
@@ -1401,6 +1778,9 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
|
|
|
1401
1778
|
try {
|
|
1402
1779
|
const { limit = 5, offset = 0 } = req.query;
|
|
1403
1780
|
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
|
|
1781
|
+
if (!['admin', 'owner'].includes(req.user?.role)) {
|
|
1782
|
+
result.sessions = (result.sessions || []).filter((session) => canUserSeeSession(req.user, session, 'claude'));
|
|
1783
|
+
}
|
|
1404
1784
|
applyCustomSessionNames(result.sessions, 'claude');
|
|
1405
1785
|
res.json(result);
|
|
1406
1786
|
}
|
|
@@ -1546,7 +1926,7 @@ const expandBrowsePath = (inputPath) => {
|
|
|
1546
1926
|
return path.resolve(trimmed);
|
|
1547
1927
|
};
|
|
1548
1928
|
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
|
1549
|
-
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
1929
|
+
app.get('/api/browse-filesystem', authenticateToken, requireAdmin, async (req, res) => {
|
|
1550
1930
|
try {
|
|
1551
1931
|
const { path: dirPath } = req.query;
|
|
1552
1932
|
console.log('[API] Browse filesystem request for path:', dirPath);
|
|
@@ -1630,7 +2010,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
|
1630
2010
|
res.status(500).json({ error: 'Failed to browse filesystem' });
|
|
1631
2011
|
}
|
|
1632
2012
|
});
|
|
1633
|
-
app.post('/api/create-folder', authenticateToken, async (req, res) => {
|
|
2013
|
+
app.post('/api/create-folder', authenticateToken, requireAdmin, async (req, res) => {
|
|
1634
2014
|
try {
|
|
1635
2015
|
const { path: folderPath } = req.body;
|
|
1636
2016
|
if (!folderPath) {
|
|
@@ -1694,6 +2074,9 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
1694
2074
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
1695
2075
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
1696
2076
|
}
|
|
2077
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'viewFiles')) {
|
|
2078
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2079
|
+
}
|
|
1697
2080
|
const content = await fsPromises.readFile(resolved, 'utf8');
|
|
1698
2081
|
res.json({ content, path: resolved });
|
|
1699
2082
|
}
|
|
@@ -1732,6 +2115,9 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
|
|
|
1732
2115
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
1733
2116
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
1734
2117
|
}
|
|
2118
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'viewFiles')) {
|
|
2119
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2120
|
+
}
|
|
1735
2121
|
// Check if file exists
|
|
1736
2122
|
try {
|
|
1737
2123
|
await fsPromises.access(resolved);
|
|
@@ -1783,6 +2169,9 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
1783
2169
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
1784
2170
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
1785
2171
|
}
|
|
2172
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'editFiles')) {
|
|
2173
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2174
|
+
}
|
|
1786
2175
|
// Write the new content
|
|
1787
2176
|
await fsPromises.writeFile(resolved, content, 'utf8');
|
|
1788
2177
|
res.json({
|
|
@@ -1829,7 +2218,12 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
|
|
|
1829
2218
|
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
|
1830
2219
|
}
|
|
1831
2220
|
const files = await getFileTree(actualPath, 10, 0, true);
|
|
1832
|
-
res.json(files
|
|
2221
|
+
res.json(filterFileTreeForUser(files, req.user, {
|
|
2222
|
+
name: req.params.projectName,
|
|
2223
|
+
projectName: req.params.projectName,
|
|
2224
|
+
fullPath: actualPath,
|
|
2225
|
+
path: actualPath,
|
|
2226
|
+
}, 'viewFiles'));
|
|
1833
2227
|
}
|
|
1834
2228
|
catch (error) {
|
|
1835
2229
|
console.error('[ERROR] File tree error:', error.message);
|
|
@@ -1909,6 +2303,9 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
|
|
|
1909
2303
|
return res.status(403).json({ error: validation.error });
|
|
1910
2304
|
}
|
|
1911
2305
|
const resolvedPath = validation.resolved;
|
|
2306
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedPath, 'editFiles')) {
|
|
2307
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2308
|
+
}
|
|
1912
2309
|
// Check if already exists
|
|
1913
2310
|
try {
|
|
1914
2311
|
await fsPromises.access(resolvedPath);
|
|
@@ -1977,6 +2374,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
|
|
|
1977
2374
|
return res.status(403).json({ error: oldValidation.error });
|
|
1978
2375
|
}
|
|
1979
2376
|
const resolvedOldPath = oldValidation.resolved;
|
|
2377
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedOldPath, 'editFiles')) {
|
|
2378
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2379
|
+
}
|
|
1980
2380
|
// Check if old path exists
|
|
1981
2381
|
try {
|
|
1982
2382
|
await fsPromises.access(resolvedOldPath);
|
|
@@ -1991,6 +2391,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
|
|
|
1991
2391
|
if (!newValidation.valid) {
|
|
1992
2392
|
return res.status(403).json({ error: newValidation.error });
|
|
1993
2393
|
}
|
|
2394
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedNewPath, 'editFiles')) {
|
|
2395
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2396
|
+
}
|
|
1994
2397
|
// Check if new path already exists
|
|
1995
2398
|
try {
|
|
1996
2399
|
await fsPromises.access(resolvedNewPath);
|
|
@@ -2045,6 +2448,9 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
|
|
|
2045
2448
|
return res.status(403).json({ error: validation.error });
|
|
2046
2449
|
}
|
|
2047
2450
|
const resolvedPath = validation.resolved;
|
|
2451
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedPath, 'editFiles')) {
|
|
2452
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2453
|
+
}
|
|
2048
2454
|
// Check if path exists and get stats
|
|
2049
2455
|
let stats;
|
|
2050
2456
|
try {
|
|
@@ -2170,6 +2576,9 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
2170
2576
|
resolvedTargetDir = validation.resolved;
|
|
2171
2577
|
console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
|
|
2172
2578
|
}
|
|
2579
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedTargetDir, 'editFiles')) {
|
|
2580
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2581
|
+
}
|
|
2173
2582
|
// Ensure target directory exists
|
|
2174
2583
|
try {
|
|
2175
2584
|
await fsPromises.access(resolvedTargetDir);
|
|
@@ -2194,6 +2603,10 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
2194
2603
|
await fsPromises.unlink(file.path).catch(() => { });
|
|
2195
2604
|
continue;
|
|
2196
2605
|
}
|
|
2606
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, destPath, 'editFiles')) {
|
|
2607
|
+
await fsPromises.unlink(file.path).catch(() => { });
|
|
2608
|
+
continue;
|
|
2609
|
+
}
|
|
2197
2610
|
// Ensure parent directory exists (for nested files from folder upload)
|
|
2198
2611
|
const parentDir = path.dirname(destPath);
|
|
2199
2612
|
try {
|
|
@@ -2313,13 +2726,35 @@ class WebSocketWriter {
|
|
|
2313
2726
|
this.ws = ws;
|
|
2314
2727
|
this.sessionId = null;
|
|
2315
2728
|
this.userId = userId;
|
|
2729
|
+
this.provider = 'claude';
|
|
2730
|
+
this.projectName = null;
|
|
2731
|
+
this.projectPath = null;
|
|
2316
2732
|
this.isWebSocketWriter = true; // Marker for transport detection
|
|
2317
2733
|
}
|
|
2318
2734
|
send(data) {
|
|
2735
|
+
const nextSessionId = data?.sessionId || data?.session_id || data?.session?.id;
|
|
2736
|
+
if (nextSessionId) {
|
|
2737
|
+
this.setSessionId(nextSessionId);
|
|
2738
|
+
recordSessionOwnership({
|
|
2739
|
+
provider: data.provider || this.provider,
|
|
2740
|
+
sessionId: nextSessionId,
|
|
2741
|
+
userId: this.userId,
|
|
2742
|
+
projectName: this.projectName,
|
|
2743
|
+
projectPath: this.projectPath,
|
|
2744
|
+
});
|
|
2745
|
+
}
|
|
2319
2746
|
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
|
2320
2747
|
this.ws.send(JSON.stringify(data));
|
|
2321
2748
|
}
|
|
2322
2749
|
}
|
|
2750
|
+
setContext({ provider, projectName, projectPath } = {}) {
|
|
2751
|
+
if (provider)
|
|
2752
|
+
this.provider = provider;
|
|
2753
|
+
if (projectName)
|
|
2754
|
+
this.projectName = projectName;
|
|
2755
|
+
if (projectPath)
|
|
2756
|
+
this.projectPath = projectPath;
|
|
2757
|
+
}
|
|
2323
2758
|
updateWebSocket(newRawWs) {
|
|
2324
2759
|
this.ws = newRawWs;
|
|
2325
2760
|
}
|
|
@@ -2339,6 +2774,43 @@ function handleChatConnection(ws, request) {
|
|
|
2339
2774
|
connectedClients.add(ws);
|
|
2340
2775
|
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
|
2341
2776
|
const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
|
|
2777
|
+
const isAdminChatUser = () => ['admin', 'owner'].includes(request?.user?.role);
|
|
2778
|
+
const requireAdminChatAction = (action) => {
|
|
2779
|
+
if (!isAdminChatUser()) {
|
|
2780
|
+
throw new Error(`${action} requires admin access until session ownership metadata is available.`);
|
|
2781
|
+
}
|
|
2782
|
+
};
|
|
2783
|
+
const assertChatCommandAccess = (data) => {
|
|
2784
|
+
const options = data?.options || {};
|
|
2785
|
+
if ((options.permissionMode === 'bypassPermissions' || options.skipPermissions === true) && !isAdminChatUser()) {
|
|
2786
|
+
throw new Error('Bypass/skip permission modes require admin access.');
|
|
2787
|
+
}
|
|
2788
|
+
const projectName = typeof options.projectName === 'string' ? options.projectName : '';
|
|
2789
|
+
const projectPath = typeof options.projectPath === 'string'
|
|
2790
|
+
? options.projectPath
|
|
2791
|
+
: (typeof options.cwd === 'string' ? options.cwd : '');
|
|
2792
|
+
const projectRef = projectName
|
|
2793
|
+
? { name: projectName, projectName }
|
|
2794
|
+
: (projectPath ? { fullPath: path.resolve(projectPath), path: path.resolve(projectPath), projectPath: path.resolve(projectPath) } : null);
|
|
2795
|
+
if (!projectRef) {
|
|
2796
|
+
if (isAdminChatUser())
|
|
2797
|
+
return;
|
|
2798
|
+
throw new Error('Project context is required for agent commands.');
|
|
2799
|
+
}
|
|
2800
|
+
if (!userHasProjectAccess(request.user, projectRef, 'chatAgents')) {
|
|
2801
|
+
throw new Error('Project access denied.');
|
|
2802
|
+
}
|
|
2803
|
+
};
|
|
2804
|
+
const setWriterCommandContext = (provider, data) => {
|
|
2805
|
+
const options = data?.options || {};
|
|
2806
|
+
writer.setContext({
|
|
2807
|
+
provider,
|
|
2808
|
+
projectName: typeof options.projectName === 'string' ? options.projectName : null,
|
|
2809
|
+
projectPath: typeof options.projectPath === 'string'
|
|
2810
|
+
? options.projectPath
|
|
2811
|
+
: (typeof options.cwd === 'string' ? options.cwd : null),
|
|
2812
|
+
});
|
|
2813
|
+
};
|
|
2342
2814
|
ws.on('message', async (message) => {
|
|
2343
2815
|
try {
|
|
2344
2816
|
const data = JSON.parse(message);
|
|
@@ -2349,31 +2821,44 @@ function handleChatConnection(ws, request) {
|
|
|
2349
2821
|
? (provider, d) => console.log(`[${provider}]`, d.command?.slice(0, 60) || '[resume]', d.options?.projectPath || d.options?.cwd || '', d.options?.sessionId ? 'resume' : 'new')
|
|
2350
2822
|
: () => { };
|
|
2351
2823
|
if (data.type === 'claude-command') {
|
|
2824
|
+
assertChatCommandAccess(data);
|
|
2825
|
+
setWriterCommandContext('claude', data);
|
|
2352
2826
|
chatDebug('claude', data);
|
|
2353
2827
|
// Use Claude Agents SDK
|
|
2354
2828
|
await queryClaudeSDK(data.command, data.options, writer);
|
|
2355
2829
|
}
|
|
2356
2830
|
else if (data.type === 'cursor-command') {
|
|
2831
|
+
assertChatCommandAccess(data);
|
|
2832
|
+
setWriterCommandContext('cursor', data);
|
|
2357
2833
|
chatDebug('cursor', data);
|
|
2358
2834
|
await spawnCursor(data.command, data.options, writer);
|
|
2359
2835
|
}
|
|
2360
2836
|
else if (data.type === 'codex-command') {
|
|
2837
|
+
assertChatCommandAccess(data);
|
|
2838
|
+
setWriterCommandContext('codex', data);
|
|
2361
2839
|
chatDebug('codex', data);
|
|
2362
2840
|
await queryCodex(data.command, data.options, writer);
|
|
2363
2841
|
}
|
|
2364
2842
|
else if (data.type === 'gemini-command') {
|
|
2843
|
+
assertChatCommandAccess(data);
|
|
2844
|
+
setWriterCommandContext('gemini', data);
|
|
2365
2845
|
chatDebug('gemini', data);
|
|
2366
2846
|
await spawnGemini(data.command, data.options, writer);
|
|
2367
2847
|
}
|
|
2368
2848
|
else if (data.type === 'qwen-command') {
|
|
2849
|
+
assertChatCommandAccess(data);
|
|
2850
|
+
setWriterCommandContext('qwen', data);
|
|
2369
2851
|
chatDebug('qwen', data);
|
|
2370
2852
|
await spawnQwen(data.command, data.options, writer);
|
|
2371
2853
|
}
|
|
2372
2854
|
else if (data.type === 'opencode-command') {
|
|
2855
|
+
assertChatCommandAccess(data);
|
|
2856
|
+
setWriterCommandContext('opencode', data);
|
|
2373
2857
|
chatDebug('opencode', data);
|
|
2374
2858
|
await spawnOpencode(data.command, data.options, writer);
|
|
2375
2859
|
}
|
|
2376
2860
|
else if (data.type === 'cursor-resume') {
|
|
2861
|
+
assertChatCommandAccess({ options: { cwd: data.options?.cwd } });
|
|
2377
2862
|
// Backward compatibility: treat as cursor-command with resume and no prompt
|
|
2378
2863
|
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
|
2379
2864
|
await spawnCursor('', {
|
|
@@ -2383,6 +2868,7 @@ function handleChatConnection(ws, request) {
|
|
|
2383
2868
|
}, writer);
|
|
2384
2869
|
}
|
|
2385
2870
|
else if (data.type === 'abort-session') {
|
|
2871
|
+
requireAdminChatAction('Aborting sessions');
|
|
2386
2872
|
console.log('[DEBUG] Abort session request:', data.sessionId);
|
|
2387
2873
|
const provider = data.provider || 'claude';
|
|
2388
2874
|
let success;
|
|
@@ -2408,6 +2894,7 @@ function handleChatConnection(ws, request) {
|
|
|
2408
2894
|
writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
|
|
2409
2895
|
}
|
|
2410
2896
|
else if (data.type === 'claude-permission-response') {
|
|
2897
|
+
requireAdminChatAction('Resolving tool approvals');
|
|
2411
2898
|
// Relay UI approval decisions back into the SDK control flow.
|
|
2412
2899
|
// This does not persist permissions; it only resolves the in-flight request,
|
|
2413
2900
|
// introduced so the SDK can resume once the user clicks Allow/Deny.
|
|
@@ -2421,11 +2908,13 @@ function handleChatConnection(ws, request) {
|
|
|
2421
2908
|
}
|
|
2422
2909
|
}
|
|
2423
2910
|
else if (data.type === 'cursor-abort') {
|
|
2911
|
+
requireAdminChatAction('Aborting sessions');
|
|
2424
2912
|
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
|
2425
2913
|
const success = abortCursorSession(data.sessionId);
|
|
2426
2914
|
writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' }));
|
|
2427
2915
|
}
|
|
2428
2916
|
else if (data.type === 'check-session-status') {
|
|
2917
|
+
requireAdminChatAction('Checking global session status');
|
|
2429
2918
|
// Check if a specific session is currently processing
|
|
2430
2919
|
const provider = data.provider || 'claude';
|
|
2431
2920
|
const sessionId = data.sessionId;
|
|
@@ -2462,6 +2951,7 @@ function handleChatConnection(ws, request) {
|
|
|
2462
2951
|
});
|
|
2463
2952
|
}
|
|
2464
2953
|
else if (data.type === 'get-pending-permissions') {
|
|
2954
|
+
requireAdminChatAction('Reading pending permissions');
|
|
2465
2955
|
// Return pending permission requests for a session
|
|
2466
2956
|
const sessionId = data.sessionId;
|
|
2467
2957
|
if (sessionId && isClaudeSDKSessionActive(sessionId)) {
|
|
@@ -2483,6 +2973,7 @@ function handleChatConnection(ws, request) {
|
|
|
2483
2973
|
unsubscribeFromWorkspace(ws, data.projectName || null);
|
|
2484
2974
|
}
|
|
2485
2975
|
else if (data.type === 'get-active-sessions') {
|
|
2976
|
+
requireAdminChatAction('Listing active sessions');
|
|
2486
2977
|
// Get all currently active sessions
|
|
2487
2978
|
const activeSessions = {
|
|
2488
2979
|
claude: getActiveClaudeSDKSessions(),
|
|
@@ -2541,11 +3032,11 @@ function handleShellConnection(ws, request) {
|
|
|
2541
3032
|
// every provider already stores its config (~/.codex etc.).
|
|
2542
3033
|
const projectPath = data.projectPath || os.homedir();
|
|
2543
3034
|
const requestedProjectPath = path.resolve(projectPath);
|
|
2544
|
-
if (!
|
|
3035
|
+
if (!userHasProjectPathAccess(request.user, {
|
|
2545
3036
|
fullPath: requestedProjectPath,
|
|
2546
3037
|
path: requestedProjectPath,
|
|
2547
3038
|
projectPath: requestedProjectPath,
|
|
2548
|
-
}, 'useShell')) {
|
|
3039
|
+
}, requestedProjectPath, 'useShell')) {
|
|
2549
3040
|
ws.send(JSON.stringify({ type: 'error', message: 'Shell access denied for this project' }));
|
|
2550
3041
|
return;
|
|
2551
3042
|
}
|
|
@@ -2594,7 +3085,8 @@ function handleShellConnection(ws, request) {
|
|
|
2594
3085
|
// which collided across providers and made every disconnect →
|
|
2595
3086
|
// switch-provider flow reopen the previously-running CLI.
|
|
2596
3087
|
const providerSuffix = isPlainShell ? '' : `_${provider}`;
|
|
2597
|
-
|
|
3088
|
+
const ownerUserId = request.user?.id ?? request.user?.userId ?? 'anonymous';
|
|
3089
|
+
ptySessionKey = `${ownerUserId}_${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
|
|
2598
3090
|
// Kill any existing login session before starting fresh
|
|
2599
3091
|
if (isLoginCommand) {
|
|
2600
3092
|
const oldSession = ptySessionsMap.get(ptySessionKey);
|
|
@@ -2610,7 +3102,7 @@ function handleShellConnection(ws, request) {
|
|
|
2610
3102
|
}
|
|
2611
3103
|
}
|
|
2612
3104
|
else {
|
|
2613
|
-
const killedSessions = killProviderPtySessions(projectPath, provider);
|
|
3105
|
+
const killedSessions = killProviderPtySessions(projectPath, provider, ownerUserId);
|
|
2614
3106
|
if (killedSessions > 0) {
|
|
2615
3107
|
console.log(`🧹 Fresh ${provider} session requested; terminated ${killedSessions} cached PTY session(s).`);
|
|
2616
3108
|
}
|
|
@@ -2846,6 +3338,7 @@ function handleShellConnection(ws, request) {
|
|
|
2846
3338
|
ws: ws,
|
|
2847
3339
|
buffer: [],
|
|
2848
3340
|
timeoutId: null,
|
|
3341
|
+
userId: ownerUserId,
|
|
2849
3342
|
projectPath,
|
|
2850
3343
|
sessionId,
|
|
2851
3344
|
hermesLaunchId,
|
|
@@ -2876,6 +3369,7 @@ function handleShellConnection(ws, request) {
|
|
|
2876
3369
|
const cleanChunk = stripAnsiSequences(data);
|
|
2877
3370
|
urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
|
|
2878
3371
|
outputData = outputData.replace(/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g, '[INFO] Opening in browser: $1');
|
|
3372
|
+
outputData = hideProviderApprovalChoiceLines(outputData);
|
|
2879
3373
|
const emitAuthUrl = (detectedUrl, autoOpen = false) => {
|
|
2880
3374
|
const normalizedUrl = normalizeDetectedUrl(detectedUrl);
|
|
2881
3375
|
if (!normalizedUrl)
|
|
@@ -2901,10 +3395,12 @@ function handleShellConnection(ws, request) {
|
|
|
2901
3395
|
emitAuthUrl(bestUrl, true);
|
|
2902
3396
|
}
|
|
2903
3397
|
// Send regular output
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
3398
|
+
if (outputData) {
|
|
3399
|
+
session.ws.send(JSON.stringify({
|
|
3400
|
+
type: 'output',
|
|
3401
|
+
data: outputData
|
|
3402
|
+
}));
|
|
3403
|
+
}
|
|
2908
3404
|
}
|
|
2909
3405
|
});
|
|
2910
3406
|
// Handle process exit
|