aicodeman 1.4.1 → 1.5.0
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/README.md +20 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +168 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/multiuser.d.ts +38 -0
- package/dist/config/multiuser.d.ts.map +1 -0
- package/dist/config/multiuser.js +58 -0
- package/dist/config/multiuser.js.map +1 -0
- package/dist/cron/cron-service.d.ts +1 -1
- package/dist/cron/cron-service.d.ts.map +1 -1
- package/dist/cron/cron-service.js +31 -4
- package/dist/cron/cron-service.js.map +1 -1
- package/dist/mux-interface.d.ts +6 -0
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/plan-orchestrator.d.ts +8 -1
- package/dist/plan-orchestrator.d.ts.map +1 -1
- package/dist/plan-orchestrator.js +20 -1
- package/dist/plan-orchestrator.js.map +1 -1
- package/dist/push-store.d.ts +17 -5
- package/dist/push-store.d.ts.map +1 -1
- package/dist/push-store.js +3 -1
- package/dist/push-store.js.map +1 -1
- package/dist/session-cli-builder.d.ts +1 -1
- package/dist/session-cli-builder.d.ts.map +1 -1
- package/dist/session-cli-builder.js +7 -2
- package/dist/session-cli-builder.js.map +1 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +19 -2
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts +15 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +21 -6
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tunnel-manager.d.ts +17 -0
- package/dist/tunnel-manager.d.ts.map +1 -1
- package/dist/tunnel-manager.js +48 -8
- package/dist/tunnel-manager.js.map +1 -1
- package/dist/types/api.d.ts +10 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/api.js +20 -0
- package/dist/types/api.js.map +1 -1
- package/dist/types/cron.d.ts +2 -0
- package/dist/types/cron.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/session.d.ts +8 -1
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/types/user.d.ts +59 -0
- package/dist/types/user.d.ts.map +1 -0
- package/dist/types/user.js +12 -0
- package/dist/types/user.js.map +1 -0
- package/dist/user-store.d.ts +139 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +433 -0
- package/dist/user-store.js.map +1 -0
- package/dist/web/admin-audit.d.ts +17 -0
- package/dist/web/admin-audit.d.ts.map +1 -0
- package/dist/web/admin-audit.js +19 -0
- package/dist/web/admin-audit.js.map +1 -0
- package/dist/web/middleware/auth.d.ts +8 -0
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +250 -51
- package/dist/web/middleware/auth.js.map +1 -1
- package/dist/web/ports/auth-port.d.ts +13 -0
- package/dist/web/ports/auth-port.d.ts.map +1 -1
- package/dist/web/ports/config-port.d.ts +4 -1
- package/dist/web/ports/config-port.d.ts.map +1 -1
- package/dist/web/ports/infra-port.d.ts +4 -1
- package/dist/web/ports/infra-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js +260 -0
- package/dist/web/public/admin-ui.js.br +0 -0
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.6c4b9893.js.gz +0 -0
- package/dist/web/public/{constants.48da7f03.js → constants.5ce97313.js} +3 -0
- package/dist/web/public/constants.5ce97313.js.br +0 -0
- package/dist/web/public/{constants.48da7f03.js.gz → constants.5ce97313.js.gz} +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +5 -3
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
- package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
- package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
- package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
- package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
- package/dist/web/public/styles.9e6cdb9d.css +1 -0
- package/dist/web/public/styles.9e6cdb9d.css.br +0 -0
- package/dist/web/public/styles.9e6cdb9d.css.gz +0 -0
- package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.efda6083.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
- package/dist/web/route-helpers.d.ts +78 -1
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +172 -3
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/admin-routes.d.ts +20 -0
- package/dist/web/routes/admin-routes.d.ts.map +1 -0
- package/dist/web/routes/admin-routes.js +193 -0
- package/dist/web/routes/admin-routes.js.map +1 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +137 -59
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/clipboard-routes.d.ts +2 -2
- package/dist/web/routes/clipboard-routes.d.ts.map +1 -1
- package/dist/web/routes/clipboard-routes.js +10 -0
- package/dist/web/routes/clipboard-routes.js.map +1 -1
- package/dist/web/routes/cron-routes.d.ts.map +1 -1
- package/dist/web/routes/cron-routes.js +57 -9
- package/dist/web/routes/cron-routes.js.map +1 -1
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +26 -20
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +2 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +2 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/me-routes.d.ts +16 -0
- package/dist/web/routes/me-routes.d.ts.map +1 -0
- package/dist/web/routes/me-routes.js +73 -0
- package/dist/web/routes/me-routes.js.map +1 -0
- package/dist/web/routes/mux-routes.d.ts.map +1 -1
- package/dist/web/routes/mux-routes.js +23 -5
- package/dist/web/routes/mux-routes.js.map +1 -1
- package/dist/web/routes/orchestrator-routes.d.ts.map +1 -1
- package/dist/web/routes/orchestrator-routes.js +43 -11
- package/dist/web/routes/orchestrator-routes.js.map +1 -1
- package/dist/web/routes/plan-routes.d.ts.map +1 -1
- package/dist/web/routes/plan-routes.js +28 -9
- package/dist/web/routes/plan-routes.js.map +1 -1
- package/dist/web/routes/push-routes.d.ts.map +1 -1
- package/dist/web/routes/push-routes.js +4 -0
- package/dist/web/routes/push-routes.js.map +1 -1
- package/dist/web/routes/ralph-routes.d.ts.map +1 -1
- package/dist/web/routes/ralph-routes.js +21 -17
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/respawn-routes.d.ts.map +1 -1
- package/dist/web/routes/respawn-routes.js +23 -14
- package/dist/web/routes/respawn-routes.js.map +1 -1
- package/dist/web/routes/scheduled-routes.d.ts.map +1 -1
- package/dist/web/routes/scheduled-routes.js +22 -6
- package/dist/web/routes/scheduled-routes.js.map +1 -1
- package/dist/web/routes/search-routes.d.ts.map +1 -1
- package/dist/web/routes/search-routes.js +9 -3
- package/dist/web/routes/search-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +188 -61
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +108 -23
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/routes/ws-routes.d.ts.map +1 -1
- package/dist/web/routes/ws-routes.js +11 -0
- package/dist/web/routes/ws-routes.js.map +1 -1
- package/dist/web/server.d.ts +12 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +187 -11
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +6 -0
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +7 -0
- package/dist/web/sse-events.js.map +1 -1
- package/dist/web/sse-stream-manager.d.ts +32 -2
- package/dist/web/sse-stream-manager.d.ts.map +1 -1
- package/dist/web/sse-stream-manager.js +51 -4
- package/dist/web/sse-stream-manager.js.map +1 -1
- package/package.json +1 -1
- package/dist/web/public/constants.48da7f03.js.br +0 -0
- package/dist/web/public/styles.ff43f80f.css +0 -1
- package/dist/web/public/styles.ff43f80f.css.br +0 -0
- package/dist/web/public/styles.ff43f80f.css.gz +0 -0
package/dist/web/server.js
CHANGED
|
@@ -90,9 +90,11 @@ import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
|
|
|
90
90
|
import { SseEvent } from './sse-events.js';
|
|
91
91
|
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
|
92
92
|
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
|
93
|
+
import { isMultiUserMode } from '../config/multiuser.js';
|
|
94
|
+
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
|
|
93
95
|
import { installRouteErrorHandler } from './route-error-handler.js';
|
|
94
96
|
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy } from './network-auth-policy.js';
|
|
95
|
-
import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerScheduledRoutes, registerHookEventRoutes, registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, registerClipboardRoutes, registerSearchRoutes, registerOrchestratorRoutes, registerCronRoutes, registerWsRoutes, } from './routes/index.js';
|
|
97
|
+
import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerScheduledRoutes, registerHookEventRoutes, registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, registerClipboardRoutes, registerSearchRoutes, registerOrchestratorRoutes, registerCronRoutes, registerMeRoutes, registerAdminRoutes, registerWsRoutes, } from './routes/index.js';
|
|
96
98
|
import { CronService } from '../cron/cron-service.js';
|
|
97
99
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
98
100
|
// Bounded, predictable shape for SSE client identifiers: alphanumerics, `_`, `-`.
|
|
@@ -174,6 +176,7 @@ export class WebServer extends EventEmitter {
|
|
|
174
176
|
authFailures = null;
|
|
175
177
|
qrAuthFailures = null;
|
|
176
178
|
hookSecretFailures = null;
|
|
179
|
+
userFailures = null;
|
|
177
180
|
pushStore = new PushSubscriptionStore();
|
|
178
181
|
teamWatcher = new TeamWatcher();
|
|
179
182
|
_orchestratorLoop = null;
|
|
@@ -212,6 +215,7 @@ export class WebServer extends EventEmitter {
|
|
|
212
215
|
const session = this.sessions.get(sessionId);
|
|
213
216
|
return session ? this.getSessionStateWithRespawn(session) : null;
|
|
214
217
|
},
|
|
218
|
+
resolveSessionOwner: (sessionId) => this.sessions.get(sessionId)?.owner,
|
|
215
219
|
}, this.cleanup);
|
|
216
220
|
// Set up mux event listeners
|
|
217
221
|
this.mux.on('sessionCreated', (session) => {
|
|
@@ -538,6 +542,7 @@ export class WebServer extends EventEmitter {
|
|
|
538
542
|
this.authFailures = authState.authFailures;
|
|
539
543
|
this.qrAuthFailures = authState.qrAuthFailures;
|
|
540
544
|
this.hookSecretFailures = authState.hookSecretFailures;
|
|
545
|
+
this.userFailures = authState.userFailures;
|
|
541
546
|
}
|
|
542
547
|
// WebSocket support (terminal I/O — low-latency bidirectional channel)
|
|
543
548
|
await this.app.register(fastifyWebsocket);
|
|
@@ -640,11 +645,11 @@ export class WebServer extends EventEmitter {
|
|
|
640
645
|
// Track tunnel clients — cloudflared proxies locally so req.ip is always
|
|
641
646
|
// 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
|
|
642
647
|
const isRemote = !!req.headers['cf-connecting-ip'];
|
|
643
|
-
this.sse.addClient(reply, sessionFilter, isRemote, clientId);
|
|
648
|
+
this.sse.addClient(reply, sessionFilter, isRemote, clientId, req.authUser);
|
|
644
649
|
// Send initial state
|
|
645
650
|
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
|
646
651
|
// Buffers are fetched on-demand when switching tabs
|
|
647
|
-
this.sse.sendSSE(reply, SseEvent.Init, this.getLightState());
|
|
652
|
+
this.sse.sendSSE(reply, SseEvent.Init, this.getLightState(req.authUser));
|
|
648
653
|
// Flush Cloudflare tunnel buffer with padding — ensures the init event
|
|
649
654
|
// (and any immediately following events) are delivered without proxy delay.
|
|
650
655
|
this.sse.sendPadding(reply);
|
|
@@ -746,6 +751,8 @@ export class WebServer extends EventEmitter {
|
|
|
746
751
|
registerPlanRoutes(this.app, ctx);
|
|
747
752
|
registerClipboardRoutes(this.app, ctx);
|
|
748
753
|
registerSearchRoutes(this.app, ctx);
|
|
754
|
+
registerMeRoutes(this.app, ctx);
|
|
755
|
+
registerAdminRoutes(this.app, ctx);
|
|
749
756
|
registerOrchestratorRoutes(this.app, ctx);
|
|
750
757
|
// Cron: build the service from the same context, recompute
|
|
751
758
|
// due times for any persisted jobs, then expose it to its routes.
|
|
@@ -1301,7 +1308,10 @@ export class WebServer extends EventEmitter {
|
|
|
1301
1308
|
const claudeMode = settings.claudeMode;
|
|
1302
1309
|
const allowedTools = settings.allowedTools;
|
|
1303
1310
|
// Only return valid modes
|
|
1304
|
-
if (claudeMode === 'dangerously-skip-permissions' ||
|
|
1311
|
+
if (claudeMode === 'dangerously-skip-permissions' ||
|
|
1312
|
+
claudeMode === 'auto' ||
|
|
1313
|
+
claudeMode === 'normal' ||
|
|
1314
|
+
claudeMode === 'allowedTools') {
|
|
1305
1315
|
return { claudeMode, allowedTools };
|
|
1306
1316
|
}
|
|
1307
1317
|
return {};
|
|
@@ -1316,7 +1326,7 @@ export class WebServer extends EventEmitter {
|
|
|
1316
1326
|
const settings = await this.readSettings();
|
|
1317
1327
|
return (settings.modelConfig || null);
|
|
1318
1328
|
}
|
|
1319
|
-
async startScheduledRun(prompt, workingDir, durationMinutes) {
|
|
1329
|
+
async startScheduledRun(prompt, workingDir, durationMinutes, owner) {
|
|
1320
1330
|
const id = uuidv4();
|
|
1321
1331
|
const now = Date.now();
|
|
1322
1332
|
const run = {
|
|
@@ -1331,6 +1341,9 @@ export class WebServer extends EventEmitter {
|
|
|
1331
1341
|
completedTasks: 0,
|
|
1332
1342
|
totalCost: 0,
|
|
1333
1343
|
logs: [`[${new Date().toISOString()}] Scheduled run started`],
|
|
1344
|
+
// Multi-user: stamp the requesting user so the spawned Session is owned +
|
|
1345
|
+
// permission-downgraded, and list/delete stay owner-scoped.
|
|
1346
|
+
owner,
|
|
1334
1347
|
};
|
|
1335
1348
|
this.scheduledRuns.set(id, run);
|
|
1336
1349
|
this.broadcast(SseEvent.ScheduledCreated, run);
|
|
@@ -1363,8 +1376,24 @@ export class WebServer extends EventEmitter {
|
|
|
1363
1376
|
}
|
|
1364
1377
|
let session = null;
|
|
1365
1378
|
try {
|
|
1366
|
-
// Create a session for this iteration
|
|
1367
|
-
|
|
1379
|
+
// Create a session for this iteration.
|
|
1380
|
+
if (isMultiUserMode()) {
|
|
1381
|
+
// §6.3: resolve the permission mode with the RUN OWNER (a non-granted user
|
|
1382
|
+
// must not regain --dangerously-skip-permissions here) and stamp the owner so
|
|
1383
|
+
// list/delete stay scoped. owner + mode + allowedTools mirror quick-start.
|
|
1384
|
+
const scheduledClaudeCfg = await this.getClaudeModeConfig();
|
|
1385
|
+
session = new Session({
|
|
1386
|
+
workingDir: run.workingDir,
|
|
1387
|
+
owner: run.owner,
|
|
1388
|
+
claudeMode: await resolveClaudeModeForUsername(scheduledClaudeCfg.claudeMode, run.owner),
|
|
1389
|
+
allowedTools: scheduledClaudeCfg.allowedTools,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
// Single-user: build EXACTLY as master (bare workingDir → Session's default
|
|
1394
|
+
// mode) so the flag-off path stays byte-identical.
|
|
1395
|
+
session = new Session({ workingDir: run.workingDir });
|
|
1396
|
+
}
|
|
1368
1397
|
this.sessions.set(session.id, session);
|
|
1369
1398
|
this.store.incrementSessionsCreated();
|
|
1370
1399
|
this.persistSessionState(session);
|
|
@@ -1490,7 +1519,53 @@ export class WebServer extends EventEmitter {
|
|
|
1490
1519
|
* Get lightweight state for SSE init - excludes full terminal buffers
|
|
1491
1520
|
* to prevent browser freezes. Terminal buffers are fetched on-demand.
|
|
1492
1521
|
*/
|
|
1493
|
-
getLightState() {
|
|
1522
|
+
getLightState(identity) {
|
|
1523
|
+
const base = this.computeLightState();
|
|
1524
|
+
// Multi-user: filter the shared cached blob per connection identity (the plan's
|
|
1525
|
+
// "filter AFTER the cache" approach). No-op for admins / single-user.
|
|
1526
|
+
if (isMultiUserMode() && identity && identity.role !== 'admin') {
|
|
1527
|
+
return this.filterLightStateForUser(base, identity.username);
|
|
1528
|
+
}
|
|
1529
|
+
return base;
|
|
1530
|
+
}
|
|
1531
|
+
/** Shallow-filter the light-state blob to what a non-admin user may see. */
|
|
1532
|
+
filterLightStateForUser(base, username) {
|
|
1533
|
+
const ownedIds = new Set();
|
|
1534
|
+
const ownedClaudeIds = new Set();
|
|
1535
|
+
for (const [id, s] of this.sessions) {
|
|
1536
|
+
if (s.owner === username) {
|
|
1537
|
+
ownedIds.add(id);
|
|
1538
|
+
if (s.claudeSessionId)
|
|
1539
|
+
ownedClaudeIds.add(s.claudeSessionId);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
const sessions = Array.isArray(base.sessions)
|
|
1543
|
+
? base.sessions.filter((s) => s.owner === username)
|
|
1544
|
+
: base.sessions;
|
|
1545
|
+
const respawnStatus = {};
|
|
1546
|
+
for (const [id, v] of Object.entries(base.respawnStatus ?? {})) {
|
|
1547
|
+
if (ownedIds.has(id))
|
|
1548
|
+
respawnStatus[id] = v;
|
|
1549
|
+
}
|
|
1550
|
+
const bySession = (arr, key) => Array.isArray(arr)
|
|
1551
|
+
? arr.filter((x) => ownedClaudeIds.has(String(x[key])))
|
|
1552
|
+
: arr;
|
|
1553
|
+
const filtered = {
|
|
1554
|
+
...base,
|
|
1555
|
+
sessions,
|
|
1556
|
+
respawnStatus,
|
|
1557
|
+
scheduledRuns: [], // legacy ScheduledRun has no owner yet → admin-only
|
|
1558
|
+
subagents: bySession(base.subagents, 'sessionId'),
|
|
1559
|
+
workflowRuns: bySession(base.workflowRuns, 'sessionUuid'),
|
|
1560
|
+
planUsage: null, // host-plan telemetry is admin-only
|
|
1561
|
+
};
|
|
1562
|
+
// #29: globalStats is a machine-wide aggregate (all users' tokens/cost + active
|
|
1563
|
+
// count) with no per-user attribution — never expose it to a non-admin. The
|
|
1564
|
+
// header falls back to per-active-session totals when it is absent.
|
|
1565
|
+
delete filtered.globalStats;
|
|
1566
|
+
return filtered;
|
|
1567
|
+
}
|
|
1568
|
+
computeLightState() {
|
|
1494
1569
|
const now = Date.now();
|
|
1495
1570
|
if (this.cachedLightState && now - this.cachedLightState.timestamp < WebServer.LIGHT_STATE_CACHE_TTL_MS) {
|
|
1496
1571
|
return this.cachedLightState.data;
|
|
@@ -1529,7 +1604,62 @@ export class WebServer extends EventEmitter {
|
|
|
1529
1604
|
this.cachedLightState = null;
|
|
1530
1605
|
this.cachedSessionsList = null;
|
|
1531
1606
|
}
|
|
1532
|
-
|
|
1607
|
+
// Multi-user: derive an ownership routing hint so an event only reaches the
|
|
1608
|
+
// clients entitled to it (no-op in single-user — hint stays undefined).
|
|
1609
|
+
this.sse.broadcast(event, data, isMultiUserMode() ? this.deriveSseHint(event, data) : undefined);
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Map an SSE event + payload to a routing hint (multi-user). Session-scoped
|
|
1613
|
+
* families resolve the owner from a sessionId in the payload (fail closed if it
|
|
1614
|
+
* can't be resolved); machine-level families are admin-only; host-plan telemetry
|
|
1615
|
+
* is admin-only; everything else stays global. Default is fail-closed for the
|
|
1616
|
+
* session-scoped prefixes so a missed field starves rather than leaks.
|
|
1617
|
+
*/
|
|
1618
|
+
deriveSseHint(event, data) {
|
|
1619
|
+
// Machine-level / host-wide: admins only.
|
|
1620
|
+
if (event.startsWith('docker:') ||
|
|
1621
|
+
event.startsWith('tunnel:') ||
|
|
1622
|
+
event.startsWith('update:') ||
|
|
1623
|
+
event.startsWith('system:') ||
|
|
1624
|
+
event.startsWith('cron:') ||
|
|
1625
|
+
event === SseEvent.SessionStatusTelemetry) {
|
|
1626
|
+
return { adminOnly: true };
|
|
1627
|
+
}
|
|
1628
|
+
// Session-scoped families: resolve the owner from the payload's session id.
|
|
1629
|
+
const SESSION_PREFIXES = [
|
|
1630
|
+
'session:',
|
|
1631
|
+
'ralph:',
|
|
1632
|
+
'respawn:',
|
|
1633
|
+
'subagent:',
|
|
1634
|
+
'workflow:',
|
|
1635
|
+
'attachment:',
|
|
1636
|
+
'task:',
|
|
1637
|
+
'mux:',
|
|
1638
|
+
'transcript:',
|
|
1639
|
+
'plan:',
|
|
1640
|
+
'orchestrator:',
|
|
1641
|
+
'hook:',
|
|
1642
|
+
'image:',
|
|
1643
|
+
'scheduled:',
|
|
1644
|
+
'team:',
|
|
1645
|
+
'case:',
|
|
1646
|
+
];
|
|
1647
|
+
if (SESSION_PREFIXES.some((p) => event.startsWith(p))) {
|
|
1648
|
+
const d = (data ?? {});
|
|
1649
|
+
const sessionId = d.sessionId ?? d.id ?? d.session?.id;
|
|
1650
|
+
const owner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
|
1651
|
+
return { owner, sessionScoped: true };
|
|
1652
|
+
}
|
|
1653
|
+
// #20/#38: clipboard:write writes into the receiver's OS clipboard — route it to
|
|
1654
|
+
// the POSTING user's own tabs only (never other users). The route stamps the
|
|
1655
|
+
// trusted caller identity as `callerUsername`. sessionScoped:true fails closed
|
|
1656
|
+
// (withhold from non-admins) if the caller identity is somehow unresolved, rather
|
|
1657
|
+
// than falling through to global delivery.
|
|
1658
|
+
if (event.startsWith('clipboard:')) {
|
|
1659
|
+
return { username: data.callerUsername, sessionScoped: true };
|
|
1660
|
+
}
|
|
1661
|
+
// Unrecognized / genuinely global events (connection status, needsRefresh): all.
|
|
1662
|
+
return undefined;
|
|
1533
1663
|
}
|
|
1534
1664
|
batchTerminalData(sessionId, data) {
|
|
1535
1665
|
this.sse.batchTerminalData(sessionId, data);
|
|
@@ -1575,6 +1705,12 @@ export class WebServer extends EventEmitter {
|
|
|
1575
1705
|
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
|
|
1576
1706
|
const sessionName = data.sessionName || '';
|
|
1577
1707
|
const sessionId = data.sessionId || '';
|
|
1708
|
+
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
|
|
1709
|
+
// must reach only the owner's devices (+ admins) — the body embeds the session
|
|
1710
|
+
// name + activity, so cross-user delivery would leak it. Resolved once here; the
|
|
1711
|
+
// per-subscription gate below is a no-op in single-user (send to all).
|
|
1712
|
+
const multiUserPush = isMultiUserMode();
|
|
1713
|
+
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
|
1578
1714
|
// Build body text from event data
|
|
1579
1715
|
let body = sessionName ? `[${sessionName}]` : '';
|
|
1580
1716
|
if (event === SseEvent.SessionError && data.error) {
|
|
@@ -1613,6 +1749,16 @@ export class WebServer extends EventEmitter {
|
|
|
1613
1749
|
// Check per-subscription preferences
|
|
1614
1750
|
if (sub.pushPreferences[event] === false)
|
|
1615
1751
|
continue;
|
|
1752
|
+
// Multi-user recipient scoping: admins receive all; a session-scoped event
|
|
1753
|
+
// reaches only subscriptions owned by the session owner (fail closed if the
|
|
1754
|
+
// owner is unresolved — legacy subs with no stamped username are excluded);
|
|
1755
|
+
// a genuinely session-less event reaches everyone.
|
|
1756
|
+
if (multiUserPush && sub.role !== 'admin') {
|
|
1757
|
+
if (sessionId) {
|
|
1758
|
+
if (sub.username === undefined || sub.username !== pushSessionOwner)
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1616
1762
|
// Re-validate the stored endpoint before fetching it server-side (SSRF, M7).
|
|
1617
1763
|
// Defense-in-depth: subscribe-time validation already rejects unsafe URLs.
|
|
1618
1764
|
if (!isSafePushEndpoint(sub.endpoint)) {
|
|
@@ -1654,6 +1800,19 @@ export class WebServer extends EventEmitter {
|
|
|
1654
1800
|
}
|
|
1655
1801
|
}
|
|
1656
1802
|
async start() {
|
|
1803
|
+
// Multi-user first boot: create the initial admin from CODEMAN_USERNAME/PASSWORD
|
|
1804
|
+
// if there are no users yet, else refuse to start (there would be no way in).
|
|
1805
|
+
if (isMultiUserMode() && !this.testMode) {
|
|
1806
|
+
const boot = await bootstrapInitialAdmin();
|
|
1807
|
+
if (boot.status === 'missing-env') {
|
|
1808
|
+
throw new Error('Multi-user mode is enabled but users.json has no users. Create the first admin with ' +
|
|
1809
|
+
'`codeman users add <name> --admin` (or set CODEMAN_USERNAME/CODEMAN_PASSWORD for one-time bootstrap).');
|
|
1810
|
+
}
|
|
1811
|
+
if (boot.status === 'created') {
|
|
1812
|
+
console.log(`✓ Multi-user: bootstrapped initial admin "${boot.username}" from CODEMAN_USERNAME/CODEMAN_PASSWORD`);
|
|
1813
|
+
}
|
|
1814
|
+
console.log('✓ Multi-user mode active (per-user accounts in users.json; CODEMAN_PASSWORD is ignored for login)');
|
|
1815
|
+
}
|
|
1657
1816
|
await this.setupRoutes();
|
|
1658
1817
|
const lifecycleLog = getLifecycleLog();
|
|
1659
1818
|
lifecycleLog.log({ event: 'server_started', sessionId: '*' });
|
|
@@ -1718,7 +1877,10 @@ export class WebServer extends EventEmitter {
|
|
|
1718
1877
|
// "just worked" before. Instead we start and warn loudly, pointing at the ways
|
|
1719
1878
|
// to secure it. --allow-unauthenticated-network just acknowledges the risk (a
|
|
1720
1879
|
// terser note). See docs/security-architecture.md.
|
|
1721
|
-
|
|
1880
|
+
// Multi-user mode with >= 1 enabled user satisfies the auth requirement even
|
|
1881
|
+
// without CODEMAN_PASSWORD (every person has their own credential).
|
|
1882
|
+
const authActive = !!process.env.CODEMAN_PASSWORD || (isMultiUserMode() && (await hasUsers()));
|
|
1883
|
+
if (!isLoopbackBindHost(this.host) && !authActive) {
|
|
1722
1884
|
if (this.allowUnauthenticatedNetwork) {
|
|
1723
1885
|
console.warn(`\n⚠ Codeman is reachable WITHOUT a password on ${displayHost}:${this.port} ` +
|
|
1724
1886
|
'(explicitly allowed). Anyone who can reach it can control your Claude sessions.\n');
|
|
@@ -1892,7 +2054,16 @@ export class WebServer extends EventEmitter {
|
|
|
1892
2054
|
// This ensures renamed sessions keep their name after server restart
|
|
1893
2055
|
const sessionName = savedState?.name || muxSession.name || muxSession.muxName;
|
|
1894
2056
|
// Create a session object for this mux session
|
|
1895
|
-
|
|
2057
|
+
// Owner round-trips like remote/docker: mux-sessions.json carries
|
|
2058
|
+
// MuxSession.owner, state.json carries SessionState.owner. Recovery must
|
|
2059
|
+
// re-resolve the permission mode with the RECOVERED owner or a reboot
|
|
2060
|
+
// would silently un-downgrade a non-granted user's restored session.
|
|
2061
|
+
const recoveredOwner = muxSession.owner ?? savedState?.owner;
|
|
2062
|
+
const recoveryClaudeModeConfig = await this.getClaudeModeConfig();
|
|
2063
|
+
const recoveryClaudeMode = {
|
|
2064
|
+
claudeMode: await resolveClaudeModeForUsername(recoveryClaudeModeConfig.claudeMode, recoveredOwner),
|
|
2065
|
+
allowedTools: recoveryClaudeModeConfig.allowedTools,
|
|
2066
|
+
};
|
|
1896
2067
|
// Recover envOverrides from the internal __envOverrides field written by
|
|
1897
2068
|
// session-manager (see updateSessionState). Cast to read the non-public field.
|
|
1898
2069
|
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
|
|
@@ -1928,6 +2099,7 @@ export class WebServer extends EventEmitter {
|
|
|
1928
2099
|
// MuxSession.docker; state.json carries SessionState.docker), so recovery
|
|
1929
2100
|
// rebuilds the `docker exec` launch instead of a broken local command.
|
|
1930
2101
|
docker: muxSession.docker ?? savedState?.docker,
|
|
2102
|
+
owner: recoveredOwner,
|
|
1931
2103
|
});
|
|
1932
2104
|
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
|
1933
2105
|
if (savedState?.name && muxSession.name !== savedState.name) {
|
|
@@ -2262,6 +2434,10 @@ export class WebServer extends EventEmitter {
|
|
|
2262
2434
|
this.hookSecretFailures.dispose();
|
|
2263
2435
|
this.hookSecretFailures = null;
|
|
2264
2436
|
}
|
|
2437
|
+
if (this.userFailures) {
|
|
2438
|
+
this.userFailures.dispose();
|
|
2439
|
+
this.userFailures = null;
|
|
2440
|
+
}
|
|
2265
2441
|
this.activePlanOrchestrators.clear();
|
|
2266
2442
|
this.cleaningUp.clear();
|
|
2267
2443
|
// Dispose push store (flush pending saves)
|