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.
Files changed (197) hide show
  1. package/README.md +20 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +168 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config/multiuser.d.ts +38 -0
  6. package/dist/config/multiuser.d.ts.map +1 -0
  7. package/dist/config/multiuser.js +58 -0
  8. package/dist/config/multiuser.js.map +1 -0
  9. package/dist/cron/cron-service.d.ts +1 -1
  10. package/dist/cron/cron-service.d.ts.map +1 -1
  11. package/dist/cron/cron-service.js +31 -4
  12. package/dist/cron/cron-service.js.map +1 -1
  13. package/dist/mux-interface.d.ts +6 -0
  14. package/dist/mux-interface.d.ts.map +1 -1
  15. package/dist/plan-orchestrator.d.ts +8 -1
  16. package/dist/plan-orchestrator.d.ts.map +1 -1
  17. package/dist/plan-orchestrator.js +20 -1
  18. package/dist/plan-orchestrator.js.map +1 -1
  19. package/dist/push-store.d.ts +17 -5
  20. package/dist/push-store.d.ts.map +1 -1
  21. package/dist/push-store.js +3 -1
  22. package/dist/push-store.js.map +1 -1
  23. package/dist/session-cli-builder.d.ts +1 -1
  24. package/dist/session-cli-builder.d.ts.map +1 -1
  25. package/dist/session-cli-builder.js +7 -2
  26. package/dist/session-cli-builder.js.map +1 -1
  27. package/dist/session.d.ts +9 -0
  28. package/dist/session.d.ts.map +1 -1
  29. package/dist/session.js +19 -2
  30. package/dist/session.js.map +1 -1
  31. package/dist/tmux-manager.d.ts +15 -1
  32. package/dist/tmux-manager.d.ts.map +1 -1
  33. package/dist/tmux-manager.js +21 -6
  34. package/dist/tmux-manager.js.map +1 -1
  35. package/dist/tunnel-manager.d.ts +17 -0
  36. package/dist/tunnel-manager.d.ts.map +1 -1
  37. package/dist/tunnel-manager.js +48 -8
  38. package/dist/tunnel-manager.js.map +1 -1
  39. package/dist/types/api.d.ts +10 -0
  40. package/dist/types/api.d.ts.map +1 -1
  41. package/dist/types/api.js +20 -0
  42. package/dist/types/api.js.map +1 -1
  43. package/dist/types/cron.d.ts +2 -0
  44. package/dist/types/cron.d.ts.map +1 -1
  45. package/dist/types/index.d.ts +1 -0
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/dist/types/index.js +1 -0
  48. package/dist/types/index.js.map +1 -1
  49. package/dist/types/session.d.ts +8 -1
  50. package/dist/types/session.d.ts.map +1 -1
  51. package/dist/types/session.js.map +1 -1
  52. package/dist/types/user.d.ts +59 -0
  53. package/dist/types/user.d.ts.map +1 -0
  54. package/dist/types/user.js +12 -0
  55. package/dist/types/user.js.map +1 -0
  56. package/dist/user-store.d.ts +139 -0
  57. package/dist/user-store.d.ts.map +1 -0
  58. package/dist/user-store.js +433 -0
  59. package/dist/user-store.js.map +1 -0
  60. package/dist/web/admin-audit.d.ts +17 -0
  61. package/dist/web/admin-audit.d.ts.map +1 -0
  62. package/dist/web/admin-audit.js +19 -0
  63. package/dist/web/admin-audit.js.map +1 -0
  64. package/dist/web/middleware/auth.d.ts +8 -0
  65. package/dist/web/middleware/auth.d.ts.map +1 -1
  66. package/dist/web/middleware/auth.js +250 -51
  67. package/dist/web/middleware/auth.js.map +1 -1
  68. package/dist/web/ports/auth-port.d.ts +13 -0
  69. package/dist/web/ports/auth-port.d.ts.map +1 -1
  70. package/dist/web/ports/config-port.d.ts +4 -1
  71. package/dist/web/ports/config-port.d.ts.map +1 -1
  72. package/dist/web/ports/infra-port.d.ts +4 -1
  73. package/dist/web/ports/infra-port.d.ts.map +1 -1
  74. package/dist/web/public/admin-ui.js +260 -0
  75. package/dist/web/public/admin-ui.js.br +0 -0
  76. package/dist/web/public/admin-ui.js.gz +0 -0
  77. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  78. package/dist/web/public/app.6c4b9893.js.gz +0 -0
  79. package/dist/web/public/{constants.48da7f03.js → constants.5ce97313.js} +3 -0
  80. package/dist/web/public/constants.5ce97313.js.br +0 -0
  81. package/dist/web/public/{constants.48da7f03.js.gz → constants.5ce97313.js.gz} +0 -0
  82. package/dist/web/public/cron-ui.js.gz +0 -0
  83. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  84. package/dist/web/public/index.html +5 -3
  85. package/dist/web/public/index.html.br +0 -0
  86. package/dist/web/public/index.html.gz +0 -0
  87. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  88. package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
  89. package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
  90. package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
  91. package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
  92. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  93. package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
  94. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  95. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  96. package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
  97. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  98. package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
  99. package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
  100. package/dist/web/public/styles.9e6cdb9d.css +1 -0
  101. package/dist/web/public/styles.9e6cdb9d.css.br +0 -0
  102. package/dist/web/public/styles.9e6cdb9d.css.gz +0 -0
  103. package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
  104. package/dist/web/public/sw.js.gz +0 -0
  105. package/dist/web/public/terminal-ui.efda6083.js.gz +0 -0
  106. package/dist/web/public/ultracode-panel.js.gz +0 -0
  107. package/dist/web/public/ultracode-windows.js.gz +0 -0
  108. package/dist/web/public/upload.html.gz +0 -0
  109. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  110. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  111. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  112. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  113. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  114. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  115. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  116. package/dist/web/public/vendor/xterm.css.gz +0 -0
  117. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  118. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  119. package/dist/web/route-helpers.d.ts +78 -1
  120. package/dist/web/route-helpers.d.ts.map +1 -1
  121. package/dist/web/route-helpers.js +172 -3
  122. package/dist/web/route-helpers.js.map +1 -1
  123. package/dist/web/routes/admin-routes.d.ts +20 -0
  124. package/dist/web/routes/admin-routes.d.ts.map +1 -0
  125. package/dist/web/routes/admin-routes.js +193 -0
  126. package/dist/web/routes/admin-routes.js.map +1 -0
  127. package/dist/web/routes/case-routes.d.ts.map +1 -1
  128. package/dist/web/routes/case-routes.js +137 -59
  129. package/dist/web/routes/case-routes.js.map +1 -1
  130. package/dist/web/routes/clipboard-routes.d.ts +2 -2
  131. package/dist/web/routes/clipboard-routes.d.ts.map +1 -1
  132. package/dist/web/routes/clipboard-routes.js +10 -0
  133. package/dist/web/routes/clipboard-routes.js.map +1 -1
  134. package/dist/web/routes/cron-routes.d.ts.map +1 -1
  135. package/dist/web/routes/cron-routes.js +57 -9
  136. package/dist/web/routes/cron-routes.js.map +1 -1
  137. package/dist/web/routes/file-routes.d.ts.map +1 -1
  138. package/dist/web/routes/file-routes.js +26 -20
  139. package/dist/web/routes/file-routes.js.map +1 -1
  140. package/dist/web/routes/index.d.ts +2 -0
  141. package/dist/web/routes/index.d.ts.map +1 -1
  142. package/dist/web/routes/index.js +2 -0
  143. package/dist/web/routes/index.js.map +1 -1
  144. package/dist/web/routes/me-routes.d.ts +16 -0
  145. package/dist/web/routes/me-routes.d.ts.map +1 -0
  146. package/dist/web/routes/me-routes.js +73 -0
  147. package/dist/web/routes/me-routes.js.map +1 -0
  148. package/dist/web/routes/mux-routes.d.ts.map +1 -1
  149. package/dist/web/routes/mux-routes.js +23 -5
  150. package/dist/web/routes/mux-routes.js.map +1 -1
  151. package/dist/web/routes/orchestrator-routes.d.ts.map +1 -1
  152. package/dist/web/routes/orchestrator-routes.js +43 -11
  153. package/dist/web/routes/orchestrator-routes.js.map +1 -1
  154. package/dist/web/routes/plan-routes.d.ts.map +1 -1
  155. package/dist/web/routes/plan-routes.js +28 -9
  156. package/dist/web/routes/plan-routes.js.map +1 -1
  157. package/dist/web/routes/push-routes.d.ts.map +1 -1
  158. package/dist/web/routes/push-routes.js +4 -0
  159. package/dist/web/routes/push-routes.js.map +1 -1
  160. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  161. package/dist/web/routes/ralph-routes.js +21 -17
  162. package/dist/web/routes/ralph-routes.js.map +1 -1
  163. package/dist/web/routes/respawn-routes.d.ts.map +1 -1
  164. package/dist/web/routes/respawn-routes.js +23 -14
  165. package/dist/web/routes/respawn-routes.js.map +1 -1
  166. package/dist/web/routes/scheduled-routes.d.ts.map +1 -1
  167. package/dist/web/routes/scheduled-routes.js +22 -6
  168. package/dist/web/routes/scheduled-routes.js.map +1 -1
  169. package/dist/web/routes/search-routes.d.ts.map +1 -1
  170. package/dist/web/routes/search-routes.js +9 -3
  171. package/dist/web/routes/search-routes.js.map +1 -1
  172. package/dist/web/routes/session-routes.d.ts.map +1 -1
  173. package/dist/web/routes/session-routes.js +188 -61
  174. package/dist/web/routes/session-routes.js.map +1 -1
  175. package/dist/web/routes/system-routes.d.ts.map +1 -1
  176. package/dist/web/routes/system-routes.js +108 -23
  177. package/dist/web/routes/system-routes.js.map +1 -1
  178. package/dist/web/routes/ws-routes.d.ts.map +1 -1
  179. package/dist/web/routes/ws-routes.js +11 -0
  180. package/dist/web/routes/ws-routes.js.map +1 -1
  181. package/dist/web/server.d.ts +12 -0
  182. package/dist/web/server.d.ts.map +1 -1
  183. package/dist/web/server.js +187 -11
  184. package/dist/web/server.js.map +1 -1
  185. package/dist/web/sse-events.d.ts +6 -0
  186. package/dist/web/sse-events.d.ts.map +1 -1
  187. package/dist/web/sse-events.js +7 -0
  188. package/dist/web/sse-events.js.map +1 -1
  189. package/dist/web/sse-stream-manager.d.ts +32 -2
  190. package/dist/web/sse-stream-manager.d.ts.map +1 -1
  191. package/dist/web/sse-stream-manager.js +51 -4
  192. package/dist/web/sse-stream-manager.js.map +1 -1
  193. package/package.json +1 -1
  194. package/dist/web/public/constants.48da7f03.js.br +0 -0
  195. package/dist/web/public/styles.ff43f80f.css +0 -1
  196. package/dist/web/public/styles.ff43f80f.css.br +0 -0
  197. package/dist/web/public/styles.ff43f80f.css.gz +0 -0
@@ -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' || claudeMode === 'normal' || claudeMode === 'allowedTools') {
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
- session = new Session({ workingDir: run.workingDir });
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
- this.sse.broadcast(event, data);
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
- if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD) {
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
- const recoveryClaudeMode = await this.getClaudeModeConfig();
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)