@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
package/server/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import path from 'path';
|
|
|
7
7
|
import os from 'os';
|
|
8
8
|
import http from 'http';
|
|
9
9
|
import net from 'node:net';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
10
11
|
import { createRequire } from 'node:module';
|
|
11
12
|
import { spawn } from 'child_process';
|
|
12
13
|
|
|
@@ -128,11 +129,11 @@ import {
|
|
|
128
129
|
import { primeCliBinPath } from './services/install-jobs.js';
|
|
129
130
|
import { buildHermesPathEnv, primeHermesPath } from './services/hermes-install-jobs.js';
|
|
130
131
|
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
|
131
|
-
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, apiKeysDb } from './database/db.js';
|
|
132
|
+
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, apiKeysDb, appConfigDb } from './database/db.js';
|
|
132
133
|
import { setNotificationWebSocketServer } from './services/notification-orchestrator.js';
|
|
133
134
|
import { configureWebPush } from './services/vapid-keys.js';
|
|
134
|
-
import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin } from './middleware/auth.js';
|
|
135
|
-
import { filterProjectsForUser, userHasProjectAccess } from './services/platformization.js';
|
|
135
|
+
import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
|
|
136
|
+
import { filterFileTreeForUser, filterProjectsForUser, userHasProjectAccess, userHasProjectPathAccess } from './services/platformization.js';
|
|
136
137
|
import { IS_PLATFORM } from './constants/config.js';
|
|
137
138
|
|
|
138
139
|
import { getConnectableHost } from '../shared/networkHosts.js';
|
|
@@ -140,6 +141,343 @@ import { getConnectableHost } from '../shared/networkHosts.js';
|
|
|
140
141
|
import { buildDaemonCliCommand, handleDaemonCommand } from './daemon-manager.js';
|
|
141
142
|
|
|
142
143
|
const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode'];
|
|
144
|
+
const SYSTEM_UPDATE_STATE_KEY = 'system_update_state';
|
|
145
|
+
const SESSION_OWNERSHIP_KEY = 'session_ownership';
|
|
146
|
+
const SYSTEM_UPDATE_LOG_LIMIT = 600;
|
|
147
|
+
const updateJobs = new Map();
|
|
148
|
+
|
|
149
|
+
function parseStoredJson(value, fallback = {}) {
|
|
150
|
+
if (!value) return fallback;
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(value);
|
|
153
|
+
} catch {
|
|
154
|
+
return fallback;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readSystemUpdateState() {
|
|
159
|
+
return parseStoredJson(appConfigDb.get(SYSTEM_UPDATE_STATE_KEY), {
|
|
160
|
+
pendingRestart: null,
|
|
161
|
+
lastAppliedUpdate: null,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeSystemUpdateState(state) {
|
|
166
|
+
appConfigDb.set(SYSTEM_UPDATE_STATE_KEY, JSON.stringify({
|
|
167
|
+
pendingRestart: state?.pendingRestart || null,
|
|
168
|
+
lastAppliedUpdate: state?.lastAppliedUpdate || null,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readSessionOwnership() {
|
|
173
|
+
return parseStoredJson(appConfigDb.get(SESSION_OWNERSHIP_KEY), {});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeSessionOwnership(ownership) {
|
|
177
|
+
appConfigDb.set(SESSION_OWNERSHIP_KEY, JSON.stringify(ownership || {}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sessionOwnershipKey(provider, sessionId) {
|
|
181
|
+
return `${provider || 'claude'}:${sessionId}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function recordSessionOwnership({ provider, sessionId, userId, projectName, projectPath }) {
|
|
185
|
+
if (!sessionId || !userId) return;
|
|
186
|
+
const ownership = readSessionOwnership();
|
|
187
|
+
const key = sessionOwnershipKey(provider, sessionId);
|
|
188
|
+
if (ownership[key]?.userId) return;
|
|
189
|
+
ownership[key] = {
|
|
190
|
+
provider: provider || 'claude',
|
|
191
|
+
sessionId,
|
|
192
|
+
userId,
|
|
193
|
+
projectName: projectName || null,
|
|
194
|
+
projectPath: projectPath || null,
|
|
195
|
+
createdAt: new Date().toISOString(),
|
|
196
|
+
};
|
|
197
|
+
writeSessionOwnership(ownership);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function canUserSeeSession(user, session, provider) {
|
|
201
|
+
if (['admin', 'owner'].includes(user?.role)) return true;
|
|
202
|
+
const sessionId = session?.id || session?.sessionId;
|
|
203
|
+
if (!sessionId) return false;
|
|
204
|
+
const owner = readSessionOwnership()[sessionOwnershipKey(provider, sessionId)];
|
|
205
|
+
return Boolean(owner?.userId && Number(owner.userId) === Number(user?.id ?? user?.userId));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function filterProjectSessionsForUser(project, user) {
|
|
209
|
+
if (['admin', 'owner'].includes(user?.role)) return project;
|
|
210
|
+
return {
|
|
211
|
+
...project,
|
|
212
|
+
sessions: (project.sessions || []).filter((session) => canUserSeeSession(user, session, 'claude')),
|
|
213
|
+
cursorSessions: (project.cursorSessions || []).filter((session) => canUserSeeSession(user, session, 'cursor')),
|
|
214
|
+
codexSessions: (project.codexSessions || []).filter((session) => canUserSeeSession(user, session, 'codex')),
|
|
215
|
+
geminiSessions: (project.geminiSessions || []).filter((session) => canUserSeeSession(user, session, 'gemini')),
|
|
216
|
+
qwenSessions: (project.qwenSessions || []).filter((session) => canUserSeeSession(user, session, 'qwen')),
|
|
217
|
+
opencodeSessions: (project.opencodeSessions || []).filter((session) => canUserSeeSession(user, session, 'opencode')),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function reconcileAppliedUpdateStateOnBoot() {
|
|
222
|
+
const state = readSystemUpdateState();
|
|
223
|
+
const pending = state.pendingRestart;
|
|
224
|
+
if (!pending?.toVersion || pending.toVersion !== SERVER_VERSION) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
writeSystemUpdateState({
|
|
229
|
+
pendingRestart: null,
|
|
230
|
+
lastAppliedUpdate: {
|
|
231
|
+
...pending,
|
|
232
|
+
appliedAt: new Date().toISOString(),
|
|
233
|
+
currentVersion: SERVER_VERSION,
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function appendUpdateJobLog(job, stream, chunk) {
|
|
239
|
+
const entry = {
|
|
240
|
+
stream,
|
|
241
|
+
chunk: String(chunk || ''),
|
|
242
|
+
timestamp: new Date().toISOString(),
|
|
243
|
+
};
|
|
244
|
+
job.logs.push(entry);
|
|
245
|
+
if (job.logs.length > SYSTEM_UPDATE_LOG_LIMIT) {
|
|
246
|
+
job.logs.splice(0, job.logs.length - SYSTEM_UPDATE_LOG_LIMIT);
|
|
247
|
+
}
|
|
248
|
+
job.updatedAt = entry.timestamp;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function snapshotUpdateJob(job) {
|
|
252
|
+
if (!job) return null;
|
|
253
|
+
return {
|
|
254
|
+
id: job.id,
|
|
255
|
+
status: job.status,
|
|
256
|
+
startedAt: job.startedAt,
|
|
257
|
+
updatedAt: job.updatedAt,
|
|
258
|
+
completedAt: job.completedAt || null,
|
|
259
|
+
fromVersion: job.fromVersion,
|
|
260
|
+
toVersion: job.toVersion || null,
|
|
261
|
+
installMode: job.installMode,
|
|
262
|
+
runtimeDir: job.runtimeDir || null,
|
|
263
|
+
alreadyLatest: Boolean(job.alreadyLatest),
|
|
264
|
+
pendingRestart: Boolean(job.pendingRestart),
|
|
265
|
+
error: job.error || null,
|
|
266
|
+
logs: job.logs,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getActiveUpdateJob() {
|
|
271
|
+
for (const job of updateJobs.values()) {
|
|
272
|
+
if (job.status === 'running' || job.status === 'queued') {
|
|
273
|
+
return job;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function readLatestPixcodePackageMetadata() {
|
|
280
|
+
const registryRes = await fetch('https://registry.npmjs.org/@pixelbyte-software/pixcode');
|
|
281
|
+
if (!registryRes.ok) throw new Error(`Registry returned HTTP ${registryRes.status}`);
|
|
282
|
+
const metadata = await registryRes.json();
|
|
283
|
+
const latestVersion = metadata['dist-tags']?.latest;
|
|
284
|
+
const latestEntry = latestVersion ? metadata.versions?.[latestVersion] : null;
|
|
285
|
+
return {
|
|
286
|
+
latestVersion,
|
|
287
|
+
tarballUrl: latestEntry?.dist?.tarball || null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function runRuntimeDirUpdateJob(job, runtimeDir, latestVersion, tarballUrl) {
|
|
292
|
+
appendUpdateJobLog(job, 'meta', `Update mode: runtime-dir\nRuntime: ${runtimeDir}\n`);
|
|
293
|
+
appendUpdateJobLog(job, 'meta', `Downloading ${tarballUrl}\n`);
|
|
294
|
+
const tarballRes = await fetch(tarballUrl);
|
|
295
|
+
if (!tarballRes.ok || !tarballRes.body) {
|
|
296
|
+
throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const stagingDir = path.join(runtimeDir, '.staging');
|
|
300
|
+
const backupDir = path.join(runtimeDir, '.previous');
|
|
301
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
302
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
303
|
+
|
|
304
|
+
const { Readable } = await import('node:stream');
|
|
305
|
+
const tarModule = await import('tar');
|
|
306
|
+
const tarExtract = tarModule.x || tarModule.default?.x;
|
|
307
|
+
if (!tarExtract) throw new Error('tar extractor not available');
|
|
308
|
+
|
|
309
|
+
await new Promise((resolve, reject) => {
|
|
310
|
+
const webStream = tarballRes.body;
|
|
311
|
+
const nodeStream = typeof Readable.fromWeb === 'function' && webStream?.getReader
|
|
312
|
+
? Readable.fromWeb(webStream)
|
|
313
|
+
: webStream;
|
|
314
|
+
const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
|
|
315
|
+
nodeStream.pipe(extractor);
|
|
316
|
+
extractor.on('finish', resolve);
|
|
317
|
+
extractor.on('error', reject);
|
|
318
|
+
nodeStream.on('error', reject);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
appendUpdateJobLog(job, 'meta', 'Staging runtime update for next restart...\n');
|
|
322
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
323
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
324
|
+
for (const entry of fs.readdirSync(stagingDir)) {
|
|
325
|
+
const src = path.join(stagingDir, entry);
|
|
326
|
+
const dst = path.join(runtimeDir, entry);
|
|
327
|
+
if (fs.existsSync(dst)) {
|
|
328
|
+
fs.renameSync(dst, path.join(backupDir, entry));
|
|
329
|
+
}
|
|
330
|
+
fs.renameSync(src, dst);
|
|
331
|
+
}
|
|
332
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
333
|
+
|
|
334
|
+
const depsChanged = (() => {
|
|
335
|
+
try {
|
|
336
|
+
const prevPkg = JSON.parse(fs.readFileSync(path.join(backupDir, 'package.json'), 'utf8'));
|
|
337
|
+
const nextPkg = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'package.json'), 'utf8'));
|
|
338
|
+
return JSON.stringify(prevPkg.dependencies || {}) !== JSON.stringify(nextPkg.dependencies || {});
|
|
339
|
+
} catch {
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
})();
|
|
343
|
+
|
|
344
|
+
if (!depsChanged) return latestVersion;
|
|
345
|
+
|
|
346
|
+
appendUpdateJobLog(job, 'meta', 'Reconciling node_modules with new package.json...\n');
|
|
347
|
+
await new Promise((resolve, reject) => {
|
|
348
|
+
const npmChild = spawn('npm', ['install', '--production', '--no-audit', '--no-fund', '--no-save'], {
|
|
349
|
+
cwd: runtimeDir,
|
|
350
|
+
env: process.env,
|
|
351
|
+
shell: true,
|
|
352
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
353
|
+
});
|
|
354
|
+
npmChild.stdout?.on('data', (chunk) => appendUpdateJobLog(job, 'stdout', chunk.toString()));
|
|
355
|
+
npmChild.stderr?.on('data', (chunk) => appendUpdateJobLog(job, 'stderr', chunk.toString()));
|
|
356
|
+
npmChild.on('error', reject);
|
|
357
|
+
npmChild.on('close', (code) => {
|
|
358
|
+
if (code === 0) resolve();
|
|
359
|
+
else reject(new Error(`npm install exited with code ${code}`));
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
return latestVersion;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function runCommandUpdateJob(job, updateCommand, updateCwd) {
|
|
366
|
+
appendUpdateJobLog(job, 'meta', `Running: ${updateCommand}\n`);
|
|
367
|
+
await new Promise((resolve, reject) => {
|
|
368
|
+
const child = spawn(updateCommand, {
|
|
369
|
+
cwd: updateCwd,
|
|
370
|
+
env: process.env,
|
|
371
|
+
shell: true,
|
|
372
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
373
|
+
});
|
|
374
|
+
child.stdout?.on('data', (data) => appendUpdateJobLog(job, 'stdout', data.toString()));
|
|
375
|
+
child.stderr?.on('data', (data) => appendUpdateJobLog(job, 'stderr', data.toString()));
|
|
376
|
+
child.on('error', reject);
|
|
377
|
+
child.on('close', (code) => {
|
|
378
|
+
if (code === 0) resolve();
|
|
379
|
+
else reject(new Error(`Update command exited with code ${code}`));
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function readCurrentPackageVersion() {
|
|
385
|
+
try {
|
|
386
|
+
const pkgRaw = fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8');
|
|
387
|
+
return JSON.parse(pkgRaw).version || SERVER_VERSION;
|
|
388
|
+
} catch {
|
|
389
|
+
return SERVER_VERSION;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function createSystemUpdateJob(actorUser) {
|
|
394
|
+
const activeJob = getActiveUpdateJob();
|
|
395
|
+
if (activeJob) return activeJob;
|
|
396
|
+
|
|
397
|
+
const job = {
|
|
398
|
+
id: crypto.randomUUID(),
|
|
399
|
+
status: 'queued',
|
|
400
|
+
startedAt: new Date().toISOString(),
|
|
401
|
+
updatedAt: new Date().toISOString(),
|
|
402
|
+
completedAt: null,
|
|
403
|
+
fromVersion: SERVER_VERSION,
|
|
404
|
+
toVersion: null,
|
|
405
|
+
installMode,
|
|
406
|
+
runtimeDir: process.env.PIXCODE_RUNTIME_DIR || null,
|
|
407
|
+
actorUserId: actorUser?.id ?? actorUser?.userId ?? null,
|
|
408
|
+
logs: [],
|
|
409
|
+
alreadyLatest: false,
|
|
410
|
+
pendingRestart: false,
|
|
411
|
+
error: null,
|
|
412
|
+
};
|
|
413
|
+
updateJobs.set(job.id, job);
|
|
414
|
+
|
|
415
|
+
void (async () => {
|
|
416
|
+
job.status = 'running';
|
|
417
|
+
appendUpdateJobLog(job, 'meta', `Background update job started at ${job.startedAt}\n`);
|
|
418
|
+
try {
|
|
419
|
+
const runtimeDir = job.runtimeDir;
|
|
420
|
+
const gitUpdateScript = path.join(APP_ROOT, 'scripts', 'update-git-install.mjs');
|
|
421
|
+
const latest = await readLatestPixcodePackageMetadata().catch((error) => {
|
|
422
|
+
appendUpdateJobLog(job, 'stderr', `Registry precheck failed: ${error.message}\n`);
|
|
423
|
+
return { latestVersion: null, tarballUrl: null };
|
|
424
|
+
});
|
|
425
|
+
job.toVersion = latest.latestVersion || null;
|
|
426
|
+
|
|
427
|
+
if (!IS_PLATFORM && installMode === 'npm' && latest.latestVersion && latest.latestVersion === SERVER_VERSION) {
|
|
428
|
+
job.status = 'completed';
|
|
429
|
+
job.alreadyLatest = true;
|
|
430
|
+
job.completedAt = new Date().toISOString();
|
|
431
|
+
appendUpdateJobLog(job, 'meta', `Already on ${SERVER_VERSION}.\n`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (runtimeDir) {
|
|
436
|
+
if (!latest.latestVersion || !latest.tarballUrl) {
|
|
437
|
+
throw new Error('Registry response missing latest version or tarball URL.');
|
|
438
|
+
}
|
|
439
|
+
job.toVersion = await runRuntimeDirUpdateJob(job, runtimeDir, latest.latestVersion, latest.tarballUrl);
|
|
440
|
+
} else {
|
|
441
|
+
const updateCommand = IS_PLATFORM
|
|
442
|
+
? 'npm run update:platform'
|
|
443
|
+
: installMode === 'git'
|
|
444
|
+
? `${JSON.stringify(process.execPath)} ${JSON.stringify(gitUpdateScript)}`
|
|
445
|
+
: 'npm install -g @pixelbyte-software/pixcode@latest';
|
|
446
|
+
const updateCwd = IS_PLATFORM || installMode === 'git' ? APP_ROOT : os.homedir();
|
|
447
|
+
await runCommandUpdateJob(job, updateCommand, updateCwd);
|
|
448
|
+
if (installMode === 'git') {
|
|
449
|
+
job.toVersion = readCurrentPackageVersion();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
job.status = 'completed';
|
|
454
|
+
job.completedAt = new Date().toISOString();
|
|
455
|
+
job.pendingRestart = true;
|
|
456
|
+
const state = readSystemUpdateState();
|
|
457
|
+
writeSystemUpdateState({
|
|
458
|
+
...state,
|
|
459
|
+
pendingRestart: {
|
|
460
|
+
jobId: job.id,
|
|
461
|
+
fromVersion: job.fromVersion,
|
|
462
|
+
toVersion: job.toVersion || latest.latestVersion || SERVER_VERSION,
|
|
463
|
+
installMode: job.installMode,
|
|
464
|
+
completedAt: job.completedAt,
|
|
465
|
+
logs: job.logs.slice(-80),
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
appendUpdateJobLog(job, 'meta', 'Update is ready. Restart when convenient to apply it.\n');
|
|
469
|
+
} catch (error) {
|
|
470
|
+
job.status = 'failed';
|
|
471
|
+
job.completedAt = new Date().toISOString();
|
|
472
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
473
|
+
appendUpdateJobLog(job, 'stderr', `Update failed: ${job.error}\n`);
|
|
474
|
+
}
|
|
475
|
+
})();
|
|
476
|
+
|
|
477
|
+
return job;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
reconcileAppliedUpdateStateOnBoot();
|
|
143
481
|
|
|
144
482
|
function requireProjectAccess(capability = 'viewFiles') {
|
|
145
483
|
return (req, res, next) => {
|
|
@@ -160,11 +498,11 @@ function requireProjectPathAccess(capability = 'viewFiles') {
|
|
|
160
498
|
return (req, res, next) => {
|
|
161
499
|
const projectPath = req.body?.projectPath || req.query.projectPath || os.homedir();
|
|
162
500
|
const resolvedProjectPath = path.resolve(String(projectPath));
|
|
163
|
-
if (!
|
|
501
|
+
if (!userHasProjectPathAccess(req.user, {
|
|
164
502
|
fullPath: resolvedProjectPath,
|
|
165
503
|
path: resolvedProjectPath,
|
|
166
504
|
projectPath: resolvedProjectPath,
|
|
167
|
-
}, capability)) {
|
|
505
|
+
}, resolvedProjectPath, capability)) {
|
|
168
506
|
return res.status(403).json({ error: 'Project access denied.' });
|
|
169
507
|
}
|
|
170
508
|
|
|
@@ -484,12 +822,13 @@ function terminatePtySession(sessionKey, session, reason) {
|
|
|
484
822
|
return true;
|
|
485
823
|
}
|
|
486
824
|
|
|
487
|
-
function killProviderPtySessions(projectPath, provider) {
|
|
825
|
+
function killProviderPtySessions(projectPath, provider, userId = null) {
|
|
488
826
|
let killed = 0;
|
|
489
827
|
for (const [sessionKey, session] of ptySessionsMap.entries()) {
|
|
490
828
|
if (
|
|
491
829
|
session?.projectPath === projectPath &&
|
|
492
830
|
session?.provider === provider &&
|
|
831
|
+
(!userId || session?.userId === userId) &&
|
|
493
832
|
!session?.isPlainShell
|
|
494
833
|
) {
|
|
495
834
|
killed += terminatePtySession(sessionKey, session, 'fresh provider session') ? 1 : 0;
|
|
@@ -762,6 +1101,25 @@ function buildProviderShellCommand(command, permissionFlags = []) {
|
|
|
762
1101
|
return flags.length > 0 ? `${command} ${flags.join(' ')}` : command;
|
|
763
1102
|
}
|
|
764
1103
|
|
|
1104
|
+
function hideProviderApprovalChoiceLines(output) {
|
|
1105
|
+
const approvalChoicePattern = /(?:^|\s)(?:[0-9]+[.)]\s*)?(?:yes,?\s+proceed|yes,?\s+and\s+don['’]?t|no,?\s+and\s+(?:tell|keep|cancel)|no,?\s+do\s+not)/iu;
|
|
1106
|
+
return String(output || '')
|
|
1107
|
+
.split(/(\r\n|\n|\r)/u)
|
|
1108
|
+
.reduce((parts, part, index, chunks) => {
|
|
1109
|
+
if (part === '\r\n' || part === '\n' || part === '\r') {
|
|
1110
|
+
const previousWasHidden = chunks[index - 1] && approvalChoicePattern.test(stripAnsiSequences(chunks[index - 1]));
|
|
1111
|
+
if (!previousWasHidden) parts.push(part);
|
|
1112
|
+
return parts;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (!approvalChoicePattern.test(stripAnsiSequences(part))) {
|
|
1116
|
+
parts.push(part);
|
|
1117
|
+
}
|
|
1118
|
+
return parts;
|
|
1119
|
+
}, [])
|
|
1120
|
+
.join('');
|
|
1121
|
+
}
|
|
1122
|
+
|
|
765
1123
|
function resolvePublicBaseUrl(request) {
|
|
766
1124
|
const headers = request?.headers || {};
|
|
767
1125
|
const forwardedProto = String(headers['x-forwarded-proto'] || '').split(',')[0].trim();
|
|
@@ -863,15 +1221,11 @@ const wss = new WebSocketServer({
|
|
|
863
1221
|
verifyClient: (info) => {
|
|
864
1222
|
console.log('WebSocket connection attempt to:', info.req.url);
|
|
865
1223
|
|
|
866
|
-
|
|
867
|
-
if (
|
|
868
|
-
const user =
|
|
869
|
-
if (!user) {
|
|
870
|
-
console.log('[WARN] Platform mode: No user found in database');
|
|
871
|
-
return false;
|
|
872
|
-
}
|
|
1224
|
+
const platformBypassUser = IS_PLATFORM ? authenticateWebSocket(null) : null;
|
|
1225
|
+
if (platformBypassUser) {
|
|
1226
|
+
const user = platformBypassUser;
|
|
873
1227
|
info.req.user = user;
|
|
874
|
-
console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
|
|
1228
|
+
console.log('[OK] Platform mode WebSocket authenticated via explicit bypass for user:', user.username);
|
|
875
1229
|
return true;
|
|
876
1230
|
}
|
|
877
1231
|
|
|
@@ -917,11 +1271,14 @@ app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
|
|
917
1271
|
|
|
918
1272
|
// Public health check endpoint (no authentication required)
|
|
919
1273
|
app.get('/health', (req, res) => {
|
|
1274
|
+
const updateState = readSystemUpdateState();
|
|
920
1275
|
res.json({
|
|
921
1276
|
status: 'ok',
|
|
922
1277
|
timestamp: new Date().toISOString(),
|
|
923
1278
|
installMode,
|
|
924
|
-
version: SERVER_VERSION
|
|
1279
|
+
version: SERVER_VERSION,
|
|
1280
|
+
pendingRestart: updateState.pendingRestart,
|
|
1281
|
+
lastAppliedUpdate: updateState.lastAppliedUpdate,
|
|
925
1282
|
});
|
|
926
1283
|
});
|
|
927
1284
|
|
|
@@ -936,7 +1293,11 @@ app.post('/api/shell/sessions/terminate', authenticateToken, requireProjectPathA
|
|
|
936
1293
|
return res.status(400).json({ error: 'Unsupported provider' });
|
|
937
1294
|
}
|
|
938
1295
|
|
|
939
|
-
const killedSessions = killProviderPtySessions(
|
|
1296
|
+
const killedSessions = killProviderPtySessions(
|
|
1297
|
+
projectPath,
|
|
1298
|
+
provider,
|
|
1299
|
+
['admin', 'owner'].includes(req.user?.role) ? null : (req.user?.id ?? req.user?.userId ?? null),
|
|
1300
|
+
);
|
|
940
1301
|
res.json({ success: true, killedSessions });
|
|
941
1302
|
});
|
|
942
1303
|
|
|
@@ -957,11 +1318,14 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
|
|
|
957
1318
|
}
|
|
958
1319
|
|
|
959
1320
|
const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
|
|
1321
|
+
const requestUserId = req.user?.id ?? req.user?.userId ?? null;
|
|
1322
|
+
const canReadAnyShellSession = ['admin', 'owner'].includes(req.user?.role);
|
|
960
1323
|
let matchedSession = null;
|
|
961
1324
|
for (const session of ptySessionsMap.values()) {
|
|
962
1325
|
if (
|
|
963
1326
|
session?.provider === provider &&
|
|
964
1327
|
!session?.isPlainShell &&
|
|
1328
|
+
(canReadAnyShellSession || session?.userId === requestUserId) &&
|
|
965
1329
|
(!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
|
|
966
1330
|
(!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)
|
|
967
1331
|
) {
|
|
@@ -1012,6 +1376,8 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
|
|
|
1012
1376
|
}
|
|
1013
1377
|
|
|
1014
1378
|
const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
|
|
1379
|
+
const requestUserId = req.user?.id ?? req.user?.userId ?? null;
|
|
1380
|
+
const canWriteAnyShellSession = ['admin', 'owner'].includes(req.user?.role);
|
|
1015
1381
|
let matchedSession = null;
|
|
1016
1382
|
for (const session of ptySessionsMap.values()) {
|
|
1017
1383
|
if (
|
|
@@ -1019,6 +1385,7 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
|
|
|
1019
1385
|
!session?.isPlainShell &&
|
|
1020
1386
|
session?.pty &&
|
|
1021
1387
|
session.lifecycleState === 'running' &&
|
|
1388
|
+
(canWriteAnyShellSession || session?.userId === requestUserId) &&
|
|
1022
1389
|
(!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
|
|
1023
1390
|
(!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)
|
|
1024
1391
|
) {
|
|
@@ -1135,14 +1502,14 @@ adapterRegistry.register(new QwenA2AAdapter());
|
|
|
1135
1502
|
adapterRegistry.register(new OpenCodeA2AAdapter());
|
|
1136
1503
|
adapterRegistry.register(new JsonEventA2AAdapter());
|
|
1137
1504
|
app.use('/hermes', createHermesTaskRouter());
|
|
1138
|
-
app.use('/preview', authenticateToken, createPreviewProxyRouter());
|
|
1139
|
-
app.use('/api/orchestration', authenticateToken, createOrchestrationTaskRouter());
|
|
1140
|
-
app.use('/api/orchestration/hermes', authenticateToken, createHermesRouter({
|
|
1505
|
+
app.use('/preview', authenticateToken, requireAdmin, createPreviewProxyRouter());
|
|
1506
|
+
app.use('/api/orchestration', authenticateToken, requireAdmin, createOrchestrationTaskRouter());
|
|
1507
|
+
app.use('/api/orchestration/hermes', authenticateToken, requireAdmin, createHermesRouter({
|
|
1141
1508
|
appRoot: APP_ROOT,
|
|
1142
1509
|
createHermesApiKey: getOrCreateHermesApiKey,
|
|
1143
1510
|
resolvePublicBaseUrl,
|
|
1144
1511
|
}));
|
|
1145
|
-
app.use('/api/orchestration', authenticateToken, createWorkflowRouter());
|
|
1512
|
+
app.use('/api/orchestration', authenticateToken, requireAdmin, createWorkflowRouter());
|
|
1146
1513
|
app.use('/live', createLiveViewPublicRouter());
|
|
1147
1514
|
|
|
1148
1515
|
// Network discovery / QR endpoints (protected)
|
|
@@ -1193,6 +1560,38 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
|
|
|
1193
1560
|
// /api/config endpoint removed - no longer needed
|
|
1194
1561
|
// Frontend now uses window.location for WebSocket URLs
|
|
1195
1562
|
|
|
1563
|
+
app.get('/api/system/update-state', authenticateToken, (req, res) => {
|
|
1564
|
+
res.json({
|
|
1565
|
+
success: true,
|
|
1566
|
+
state: readSystemUpdateState(),
|
|
1567
|
+
activeJob: snapshotUpdateJob(getActiveUpdateJob()),
|
|
1568
|
+
currentVersion: SERVER_VERSION,
|
|
1569
|
+
installMode,
|
|
1570
|
+
capabilities: {
|
|
1571
|
+
canBackgroundUpdate: true,
|
|
1572
|
+
canRestart: true,
|
|
1573
|
+
startupUpdateDefault: false,
|
|
1574
|
+
},
|
|
1575
|
+
});
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
app.post('/api/system/update-jobs', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
|
|
1579
|
+
const job = createSystemUpdateJob(req.user);
|
|
1580
|
+
res.status(job.status === 'queued' ? 202 : 200).json({
|
|
1581
|
+
success: true,
|
|
1582
|
+
job: snapshotUpdateJob(job),
|
|
1583
|
+
});
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
app.get('/api/system/update-jobs/:jobId', authenticateToken, requireAdmin, requireApiScope('system:update'), (req, res) => {
|
|
1587
|
+
const job = updateJobs.get(req.params.jobId);
|
|
1588
|
+
if (!job) {
|
|
1589
|
+
return res.status(404).json({ success: false, error: 'Update job not found' });
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
res.json({ success: true, job: snapshotUpdateJob(job) });
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1196
1595
|
// System update endpoint — streams live output via Server-Sent Events so the
|
|
1197
1596
|
// UI sees npm/git progress in real time instead of waiting ~2 minutes for the
|
|
1198
1597
|
// buffered response.
|
|
@@ -1206,7 +1605,7 @@ app.use(express.static(path.join(APP_ROOT, 'public'), {
|
|
|
1206
1605
|
// checkout state before pulling so source installs do not fail on local
|
|
1207
1606
|
// modified files left by older releases or manual edits.
|
|
1208
1607
|
// 3. fallback → `npm install -g …` (classic npm-distributed install).
|
|
1209
|
-
app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|
1608
|
+
app.post('/api/system/update', authenticateToken, requireAdmin, requireApiScope('system:update'), async (req, res) => {
|
|
1210
1609
|
const projectRoot = APP_ROOT;
|
|
1211
1610
|
console.log('Starting system update from directory:', projectRoot);
|
|
1212
1611
|
|
|
@@ -1563,7 +1962,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|
|
1563
1962
|
// Restart endpoint — exits the current process so an external wrapper
|
|
1564
1963
|
// (systemd/pm2/daemon manager) can bring the server back on the new code.
|
|
1565
1964
|
// Foreground installs without a wrapper will simply stop; the UI reports this.
|
|
1566
|
-
app.post('/api/system/restart', authenticateToken, (req, res) => {
|
|
1965
|
+
app.post('/api/system/restart', authenticateToken, requireAdmin, requireApiScope('system:restart'), (req, res) => {
|
|
1567
1966
|
res.json({
|
|
1568
1967
|
success: true,
|
|
1569
1968
|
version: SERVER_VERSION,
|
|
@@ -1580,7 +1979,7 @@ app.post('/api/system/restart', authenticateToken, (req, res) => {
|
|
|
1580
1979
|
app.get('/api/projects', authenticateToken, async (req, res) => {
|
|
1581
1980
|
try {
|
|
1582
1981
|
const projects = await getProjects(broadcastProgress);
|
|
1583
|
-
res.json(filterProjectsForUser(projects, req.user));
|
|
1982
|
+
res.json(filterProjectsForUser(projects, req.user).map((project) => filterProjectSessionsForUser(project, req.user)));
|
|
1584
1983
|
} catch (error) {
|
|
1585
1984
|
res.status(500).json({ error: error.message });
|
|
1586
1985
|
}
|
|
@@ -1590,6 +1989,9 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, requireProject
|
|
|
1590
1989
|
try {
|
|
1591
1990
|
const { limit = 5, offset = 0 } = req.query;
|
|
1592
1991
|
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
|
|
1992
|
+
if (!['admin', 'owner'].includes(req.user?.role)) {
|
|
1993
|
+
result.sessions = (result.sessions || []).filter((session) => canUserSeeSession(req.user, session, 'claude'));
|
|
1994
|
+
}
|
|
1593
1995
|
applyCustomSessionNames(result.sessions, 'claude');
|
|
1594
1996
|
res.json(result);
|
|
1595
1997
|
} catch (error) {
|
|
@@ -1735,7 +2137,7 @@ const expandBrowsePath = (inputPath) => {
|
|
|
1735
2137
|
};
|
|
1736
2138
|
|
|
1737
2139
|
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
|
1738
|
-
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
2140
|
+
app.get('/api/browse-filesystem', authenticateToken, requireAdmin, async (req, res) => {
|
|
1739
2141
|
try {
|
|
1740
2142
|
const { path: dirPath } = req.query;
|
|
1741
2143
|
|
|
@@ -1824,7 +2226,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
|
|
1824
2226
|
}
|
|
1825
2227
|
});
|
|
1826
2228
|
|
|
1827
|
-
app.post('/api/create-folder', authenticateToken, async (req, res) => {
|
|
2229
|
+
app.post('/api/create-folder', authenticateToken, requireAdmin, async (req, res) => {
|
|
1828
2230
|
try {
|
|
1829
2231
|
const { path: folderPath } = req.body;
|
|
1830
2232
|
if (!folderPath) {
|
|
@@ -1889,6 +2291,9 @@ app.get('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
1889
2291
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
1890
2292
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
1891
2293
|
}
|
|
2294
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'viewFiles')) {
|
|
2295
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2296
|
+
}
|
|
1892
2297
|
|
|
1893
2298
|
const content = await fsPromises.readFile(resolved, 'utf8');
|
|
1894
2299
|
res.json({ content, path: resolved });
|
|
@@ -1930,6 +2335,9 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, requirePr
|
|
|
1930
2335
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
1931
2336
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
1932
2337
|
}
|
|
2338
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'viewFiles')) {
|
|
2339
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2340
|
+
}
|
|
1933
2341
|
|
|
1934
2342
|
// Check if file exists
|
|
1935
2343
|
try {
|
|
@@ -1990,6 +2398,9 @@ app.put('/api/projects/:projectName/file', authenticateToken, requireProjectAcce
|
|
|
1990
2398
|
if (!resolved.startsWith(normalizedRoot)) {
|
|
1991
2399
|
return res.status(403).json({ error: 'Path must be under project root' });
|
|
1992
2400
|
}
|
|
2401
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolved, 'editFiles')) {
|
|
2402
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2403
|
+
}
|
|
1993
2404
|
|
|
1994
2405
|
// Write the new content
|
|
1995
2406
|
await fsPromises.writeFile(resolved, content, 'utf8');
|
|
@@ -2038,7 +2449,12 @@ app.get('/api/projects/:projectName/files', authenticateToken, requireProjectAcc
|
|
|
2038
2449
|
}
|
|
2039
2450
|
|
|
2040
2451
|
const files = await getFileTree(actualPath, 10, 0, true);
|
|
2041
|
-
res.json(files
|
|
2452
|
+
res.json(filterFileTreeForUser(files, req.user, {
|
|
2453
|
+
name: req.params.projectName,
|
|
2454
|
+
projectName: req.params.projectName,
|
|
2455
|
+
fullPath: actualPath,
|
|
2456
|
+
path: actualPath,
|
|
2457
|
+
}, 'viewFiles'));
|
|
2042
2458
|
} catch (error) {
|
|
2043
2459
|
console.error('[ERROR] File tree error:', error.message);
|
|
2044
2460
|
res.status(500).json({ error: error.message });
|
|
@@ -2127,6 +2543,9 @@ app.post('/api/projects/:projectName/files/create', authenticateToken, requirePr
|
|
|
2127
2543
|
}
|
|
2128
2544
|
|
|
2129
2545
|
const resolvedPath = validation.resolved;
|
|
2546
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedPath, 'editFiles')) {
|
|
2547
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2548
|
+
}
|
|
2130
2549
|
|
|
2131
2550
|
// Check if already exists
|
|
2132
2551
|
try {
|
|
@@ -2198,6 +2617,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
|
|
|
2198
2617
|
}
|
|
2199
2618
|
|
|
2200
2619
|
const resolvedOldPath = oldValidation.resolved;
|
|
2620
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedOldPath, 'editFiles')) {
|
|
2621
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2622
|
+
}
|
|
2201
2623
|
|
|
2202
2624
|
// Check if old path exists
|
|
2203
2625
|
try {
|
|
@@ -2213,6 +2635,9 @@ app.put('/api/projects/:projectName/files/rename', authenticateToken, requirePro
|
|
|
2213
2635
|
if (!newValidation.valid) {
|
|
2214
2636
|
return res.status(403).json({ error: newValidation.error });
|
|
2215
2637
|
}
|
|
2638
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedNewPath, 'editFiles')) {
|
|
2639
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2640
|
+
}
|
|
2216
2641
|
|
|
2217
2642
|
// Check if new path already exists
|
|
2218
2643
|
try {
|
|
@@ -2270,6 +2695,9 @@ app.delete('/api/projects/:projectName/files', authenticateToken, requireProject
|
|
|
2270
2695
|
}
|
|
2271
2696
|
|
|
2272
2697
|
const resolvedPath = validation.resolved;
|
|
2698
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedPath, 'editFiles')) {
|
|
2699
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2700
|
+
}
|
|
2273
2701
|
|
|
2274
2702
|
// Check if path exists and get stats
|
|
2275
2703
|
let stats;
|
|
@@ -2403,6 +2831,9 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
2403
2831
|
resolvedTargetDir = validation.resolved;
|
|
2404
2832
|
console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
|
|
2405
2833
|
}
|
|
2834
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, resolvedTargetDir, 'editFiles')) {
|
|
2835
|
+
return res.status(403).json({ error: 'Folder access denied' });
|
|
2836
|
+
}
|
|
2406
2837
|
|
|
2407
2838
|
// Ensure target directory exists
|
|
2408
2839
|
try {
|
|
@@ -2429,6 +2860,10 @@ const uploadFilesHandler = async (req, res) => {
|
|
|
2429
2860
|
await fsPromises.unlink(file.path).catch(() => {});
|
|
2430
2861
|
continue;
|
|
2431
2862
|
}
|
|
2863
|
+
if (!userHasProjectPathAccess(req.user, { name: projectName, projectName, fullPath: projectRoot, path: projectRoot }, destPath, 'editFiles')) {
|
|
2864
|
+
await fsPromises.unlink(file.path).catch(() => {});
|
|
2865
|
+
continue;
|
|
2866
|
+
}
|
|
2432
2867
|
|
|
2433
2868
|
// Ensure parent directory exists (for nested files from folder upload)
|
|
2434
2869
|
const parentDir = path.dirname(destPath);
|
|
@@ -2552,15 +2987,35 @@ class WebSocketWriter {
|
|
|
2552
2987
|
this.ws = ws;
|
|
2553
2988
|
this.sessionId = null;
|
|
2554
2989
|
this.userId = userId;
|
|
2990
|
+
this.provider = 'claude';
|
|
2991
|
+
this.projectName = null;
|
|
2992
|
+
this.projectPath = null;
|
|
2555
2993
|
this.isWebSocketWriter = true; // Marker for transport detection
|
|
2556
2994
|
}
|
|
2557
2995
|
|
|
2558
2996
|
send(data) {
|
|
2997
|
+
const nextSessionId = data?.sessionId || data?.session_id || data?.session?.id;
|
|
2998
|
+
if (nextSessionId) {
|
|
2999
|
+
this.setSessionId(nextSessionId);
|
|
3000
|
+
recordSessionOwnership({
|
|
3001
|
+
provider: data.provider || this.provider,
|
|
3002
|
+
sessionId: nextSessionId,
|
|
3003
|
+
userId: this.userId,
|
|
3004
|
+
projectName: this.projectName,
|
|
3005
|
+
projectPath: this.projectPath,
|
|
3006
|
+
});
|
|
3007
|
+
}
|
|
2559
3008
|
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
|
2560
3009
|
this.ws.send(JSON.stringify(data));
|
|
2561
3010
|
}
|
|
2562
3011
|
}
|
|
2563
3012
|
|
|
3013
|
+
setContext({ provider, projectName, projectPath } = {}) {
|
|
3014
|
+
if (provider) this.provider = provider;
|
|
3015
|
+
if (projectName) this.projectName = projectName;
|
|
3016
|
+
if (projectPath) this.projectPath = projectPath;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
2564
3019
|
updateWebSocket(newRawWs) {
|
|
2565
3020
|
this.ws = newRawWs;
|
|
2566
3021
|
}
|
|
@@ -2585,6 +3040,45 @@ function handleChatConnection(ws, request) {
|
|
|
2585
3040
|
|
|
2586
3041
|
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
|
2587
3042
|
const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
|
|
3043
|
+
const isAdminChatUser = () => ['admin', 'owner'].includes(request?.user?.role);
|
|
3044
|
+
const requireAdminChatAction = (action) => {
|
|
3045
|
+
if (!isAdminChatUser()) {
|
|
3046
|
+
throw new Error(`${action} requires admin access until session ownership metadata is available.`);
|
|
3047
|
+
}
|
|
3048
|
+
};
|
|
3049
|
+
const assertChatCommandAccess = (data) => {
|
|
3050
|
+
const options = data?.options || {};
|
|
3051
|
+
if ((options.permissionMode === 'bypassPermissions' || options.skipPermissions === true) && !isAdminChatUser()) {
|
|
3052
|
+
throw new Error('Bypass/skip permission modes require admin access.');
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
const projectName = typeof options.projectName === 'string' ? options.projectName : '';
|
|
3056
|
+
const projectPath = typeof options.projectPath === 'string'
|
|
3057
|
+
? options.projectPath
|
|
3058
|
+
: (typeof options.cwd === 'string' ? options.cwd : '');
|
|
3059
|
+
const projectRef = projectName
|
|
3060
|
+
? { name: projectName, projectName }
|
|
3061
|
+
: (projectPath ? { fullPath: path.resolve(projectPath), path: path.resolve(projectPath), projectPath: path.resolve(projectPath) } : null);
|
|
3062
|
+
|
|
3063
|
+
if (!projectRef) {
|
|
3064
|
+
if (isAdminChatUser()) return;
|
|
3065
|
+
throw new Error('Project context is required for agent commands.');
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
if (!userHasProjectAccess(request.user, projectRef, 'chatAgents')) {
|
|
3069
|
+
throw new Error('Project access denied.');
|
|
3070
|
+
}
|
|
3071
|
+
};
|
|
3072
|
+
const setWriterCommandContext = (provider, data) => {
|
|
3073
|
+
const options = data?.options || {};
|
|
3074
|
+
writer.setContext({
|
|
3075
|
+
provider,
|
|
3076
|
+
projectName: typeof options.projectName === 'string' ? options.projectName : null,
|
|
3077
|
+
projectPath: typeof options.projectPath === 'string'
|
|
3078
|
+
? options.projectPath
|
|
3079
|
+
: (typeof options.cwd === 'string' ? options.cwd : null),
|
|
3080
|
+
});
|
|
3081
|
+
};
|
|
2588
3082
|
|
|
2589
3083
|
ws.on('message', async (message) => {
|
|
2590
3084
|
try {
|
|
@@ -2601,25 +3095,38 @@ function handleChatConnection(ws, request) {
|
|
|
2601
3095
|
: () => {};
|
|
2602
3096
|
|
|
2603
3097
|
if (data.type === 'claude-command') {
|
|
3098
|
+
assertChatCommandAccess(data);
|
|
3099
|
+
setWriterCommandContext('claude', data);
|
|
2604
3100
|
chatDebug('claude', data);
|
|
2605
3101
|
// Use Claude Agents SDK
|
|
2606
3102
|
await queryClaudeSDK(data.command, data.options, writer);
|
|
2607
3103
|
} else if (data.type === 'cursor-command') {
|
|
3104
|
+
assertChatCommandAccess(data);
|
|
3105
|
+
setWriterCommandContext('cursor', data);
|
|
2608
3106
|
chatDebug('cursor', data);
|
|
2609
3107
|
await spawnCursor(data.command, data.options, writer);
|
|
2610
3108
|
} else if (data.type === 'codex-command') {
|
|
3109
|
+
assertChatCommandAccess(data);
|
|
3110
|
+
setWriterCommandContext('codex', data);
|
|
2611
3111
|
chatDebug('codex', data);
|
|
2612
3112
|
await queryCodex(data.command, data.options, writer);
|
|
2613
3113
|
} else if (data.type === 'gemini-command') {
|
|
3114
|
+
assertChatCommandAccess(data);
|
|
3115
|
+
setWriterCommandContext('gemini', data);
|
|
2614
3116
|
chatDebug('gemini', data);
|
|
2615
3117
|
await spawnGemini(data.command, data.options, writer);
|
|
2616
3118
|
} else if (data.type === 'qwen-command') {
|
|
3119
|
+
assertChatCommandAccess(data);
|
|
3120
|
+
setWriterCommandContext('qwen', data);
|
|
2617
3121
|
chatDebug('qwen', data);
|
|
2618
3122
|
await spawnQwen(data.command, data.options, writer);
|
|
2619
3123
|
} else if (data.type === 'opencode-command') {
|
|
3124
|
+
assertChatCommandAccess(data);
|
|
3125
|
+
setWriterCommandContext('opencode', data);
|
|
2620
3126
|
chatDebug('opencode', data);
|
|
2621
3127
|
await spawnOpencode(data.command, data.options, writer);
|
|
2622
3128
|
} else if (data.type === 'cursor-resume') {
|
|
3129
|
+
assertChatCommandAccess({ options: { cwd: data.options?.cwd } });
|
|
2623
3130
|
// Backward compatibility: treat as cursor-command with resume and no prompt
|
|
2624
3131
|
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
|
2625
3132
|
await spawnCursor('', {
|
|
@@ -2628,6 +3135,7 @@ function handleChatConnection(ws, request) {
|
|
|
2628
3135
|
cwd: data.options?.cwd
|
|
2629
3136
|
}, writer);
|
|
2630
3137
|
} else if (data.type === 'abort-session') {
|
|
3138
|
+
requireAdminChatAction('Aborting sessions');
|
|
2631
3139
|
console.log('[DEBUG] Abort session request:', data.sessionId);
|
|
2632
3140
|
const provider = data.provider || 'claude';
|
|
2633
3141
|
let success;
|
|
@@ -2649,6 +3157,7 @@ function handleChatConnection(ws, request) {
|
|
|
2649
3157
|
|
|
2650
3158
|
writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));
|
|
2651
3159
|
} else if (data.type === 'claude-permission-response') {
|
|
3160
|
+
requireAdminChatAction('Resolving tool approvals');
|
|
2652
3161
|
// Relay UI approval decisions back into the SDK control flow.
|
|
2653
3162
|
// This does not persist permissions; it only resolves the in-flight request,
|
|
2654
3163
|
// introduced so the SDK can resume once the user clicks Allow/Deny.
|
|
@@ -2661,10 +3170,12 @@ function handleChatConnection(ws, request) {
|
|
|
2661
3170
|
});
|
|
2662
3171
|
}
|
|
2663
3172
|
} else if (data.type === 'cursor-abort') {
|
|
3173
|
+
requireAdminChatAction('Aborting sessions');
|
|
2664
3174
|
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
|
2665
3175
|
const success = abortCursorSession(data.sessionId);
|
|
2666
3176
|
writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' }));
|
|
2667
3177
|
} else if (data.type === 'check-session-status') {
|
|
3178
|
+
requireAdminChatAction('Checking global session status');
|
|
2668
3179
|
// Check if a specific session is currently processing
|
|
2669
3180
|
const provider = data.provider || 'claude';
|
|
2670
3181
|
const sessionId = data.sessionId;
|
|
@@ -2697,6 +3208,7 @@ function handleChatConnection(ws, request) {
|
|
|
2697
3208
|
isProcessing: isActive
|
|
2698
3209
|
});
|
|
2699
3210
|
} else if (data.type === 'get-pending-permissions') {
|
|
3211
|
+
requireAdminChatAction('Reading pending permissions');
|
|
2700
3212
|
// Return pending permission requests for a session
|
|
2701
3213
|
const sessionId = data.sessionId;
|
|
2702
3214
|
if (sessionId && isClaudeSDKSessionActive(sessionId)) {
|
|
@@ -2715,6 +3227,7 @@ function handleChatConnection(ws, request) {
|
|
|
2715
3227
|
} else if (data.type === 'unwatch-project') {
|
|
2716
3228
|
unsubscribeFromWorkspace(ws, data.projectName || null);
|
|
2717
3229
|
} else if (data.type === 'get-active-sessions') {
|
|
3230
|
+
requireAdminChatAction('Listing active sessions');
|
|
2718
3231
|
// Get all currently active sessions
|
|
2719
3232
|
const activeSessions = {
|
|
2720
3233
|
claude: getActiveClaudeSDKSessions(),
|
|
@@ -2776,11 +3289,11 @@ function handleShellConnection(ws, request) {
|
|
|
2776
3289
|
// every provider already stores its config (~/.codex etc.).
|
|
2777
3290
|
const projectPath = data.projectPath || os.homedir();
|
|
2778
3291
|
const requestedProjectPath = path.resolve(projectPath);
|
|
2779
|
-
if (!
|
|
3292
|
+
if (!userHasProjectPathAccess(request.user, {
|
|
2780
3293
|
fullPath: requestedProjectPath,
|
|
2781
3294
|
path: requestedProjectPath,
|
|
2782
3295
|
projectPath: requestedProjectPath,
|
|
2783
|
-
}, 'useShell')) {
|
|
3296
|
+
}, requestedProjectPath, 'useShell')) {
|
|
2784
3297
|
ws.send(JSON.stringify({ type: 'error', message: 'Shell access denied for this project' }));
|
|
2785
3298
|
return;
|
|
2786
3299
|
}
|
|
@@ -2833,7 +3346,8 @@ function handleShellConnection(ws, request) {
|
|
|
2833
3346
|
// which collided across providers and made every disconnect →
|
|
2834
3347
|
// switch-provider flow reopen the previously-running CLI.
|
|
2835
3348
|
const providerSuffix = isPlainShell ? '' : `_${provider}`;
|
|
2836
|
-
|
|
3349
|
+
const ownerUserId = request.user?.id ?? request.user?.userId ?? 'anonymous';
|
|
3350
|
+
ptySessionKey = `${ownerUserId}_${projectPath}_${sessionId || 'default'}${providerSuffix}${commandSuffix}`;
|
|
2837
3351
|
|
|
2838
3352
|
// Kill any existing login session before starting fresh
|
|
2839
3353
|
if (isLoginCommand) {
|
|
@@ -2848,7 +3362,7 @@ function handleShellConnection(ws, request) {
|
|
|
2848
3362
|
terminatePtySession(ptySessionKey, oldSession, 'fresh plain shell session');
|
|
2849
3363
|
}
|
|
2850
3364
|
} else {
|
|
2851
|
-
const killedSessions = killProviderPtySessions(projectPath, provider);
|
|
3365
|
+
const killedSessions = killProviderPtySessions(projectPath, provider, ownerUserId);
|
|
2852
3366
|
if (killedSessions > 0) {
|
|
2853
3367
|
console.log(`🧹 Fresh ${provider} session requested; terminated ${killedSessions} cached PTY session(s).`);
|
|
2854
3368
|
}
|
|
@@ -3084,6 +3598,7 @@ function handleShellConnection(ws, request) {
|
|
|
3084
3598
|
ws: ws,
|
|
3085
3599
|
buffer: [],
|
|
3086
3600
|
timeoutId: null,
|
|
3601
|
+
userId: ownerUserId,
|
|
3087
3602
|
projectPath,
|
|
3088
3603
|
sessionId,
|
|
3089
3604
|
hermesLaunchId,
|
|
@@ -3121,6 +3636,7 @@ function handleShellConnection(ws, request) {
|
|
|
3121
3636
|
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
|
3122
3637
|
'[INFO] Opening in browser: $1'
|
|
3123
3638
|
);
|
|
3639
|
+
outputData = hideProviderApprovalChoiceLines(outputData);
|
|
3124
3640
|
|
|
3125
3641
|
const emitAuthUrl = (detectedUrl, autoOpen = false) => {
|
|
3126
3642
|
const normalizedUrl = normalizeDetectedUrl(detectedUrl);
|
|
@@ -3157,10 +3673,12 @@ function handleShellConnection(ws, request) {
|
|
|
3157
3673
|
}
|
|
3158
3674
|
|
|
3159
3675
|
// Send regular output
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3676
|
+
if (outputData) {
|
|
3677
|
+
session.ws.send(JSON.stringify({
|
|
3678
|
+
type: 'output',
|
|
3679
|
+
data: outputData
|
|
3680
|
+
}));
|
|
3681
|
+
}
|
|
3164
3682
|
}
|
|
3165
3683
|
});
|
|
3166
3684
|
|