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
@@ -13,7 +13,9 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types
13
13
  import { Session, isAltScreenStripMode } from '../../session.js';
14
14
  import { SseEvent } from '../sse-events.js';
15
15
  import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, } from '../schemas.js';
16
- import { autoConfigureRalph, CASES_DIR, findSessionOrFail, parseBody, persistAndBroadcastSession, SETTINGS_PATH, validatePathWithinBase, } from '../route-helpers.js';
16
+ import { autoConfigureRalph, canAccessOwned, CASES_DIR, findSessionOrFail, getAuthUser, isAdmin, isWorkingDirAllowed, ownerFor, parseBody, persistAndBroadcastSession, resolveCasesDir, sessionCapacityMessage, SETTINGS_PATH, validatePathWithinBase, } from '../route-helpers.js';
17
+ import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
18
+ import { isMultiUserMode } from '../../config/multiuser.js';
17
19
  import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
18
20
  import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyStatusLineConfig, refreshStaleHookSecret, } from '../../hooks-config.js';
19
21
  import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -21,7 +23,6 @@ import { imageWatcher } from '../../image-watcher.js';
21
23
  import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
22
24
  import { getLifecycleLog } from '../../session-lifecycle-log.js';
23
25
  import { mergeUnifiedSessions, filterAndPaginate, } from '../../services/unified-session-service.js';
24
- import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
25
26
  import { RunSummaryTracker } from '../../run-summary.js';
26
27
  import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
27
28
  import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
@@ -197,6 +198,25 @@ export function consumePasteToken(key, now = Date.now()) {
197
198
  export function _resetPasteRateBuckets() {
198
199
  pasteRateBuckets.clear();
199
200
  }
201
+ /**
202
+ * Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
203
+ * cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`
204
+ * and Gemini `--approval-mode yolo` disable the safety classifier the non-granted-user
205
+ * downgrade is meant to keep on, so clamp them for a non-granted owner. buildGeminiCommand
206
+ * defaults an ABSENT approvalMode to yolo, so the gemini config must be MATERIALIZED
207
+ * (auto_edit) even when the request sent none. No-op in single-user mode / for a granted
208
+ * owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
209
+ */
210
+ async function clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig) {
211
+ const granted = await canUsernameRunPrivilegedCommands(owner);
212
+ if (granted)
213
+ return { codexConfig, geminiConfig };
214
+ // Non-granted: force codex bypass off (only meaningful when a config was sent) and
215
+ // materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
216
+ const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
217
+ const clampedGemini = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
218
+ return { codexConfig: clampedCodex, geminiConfig: clampedGemini };
219
+ }
200
220
  export function registerSessionRoutes(app, ctx) {
201
221
  // ═══════════════════════════════════════════════════════════════
202
222
  // Auth
@@ -215,17 +235,36 @@ export function registerSessionRoutes(app, ctx) {
215
235
  // Session CRUD (list, create, rename, color, delete, detail)
216
236
  // ═══════════════════════════════════════════════════════════════
217
237
  // ========== Session Listing ==========
218
- app.get('/api/sessions', async () => {
219
- return ctx.getLightSessionsState();
238
+ app.get('/api/sessions', async (req) => {
239
+ const list = ctx.getLightSessionsState();
240
+ if (!isMultiUserMode())
241
+ return list;
242
+ const user = getAuthUser(req);
243
+ if (user.role === 'admin')
244
+ return list;
245
+ return list.filter((s) => canAccessOwned(user, s.owner));
220
246
  });
221
247
  // ========== Session Creation ==========
222
248
  app.post('/api/sessions', async (req) => {
223
- // Prevent unbounded session creation
224
- if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
225
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`);
226
- }
249
+ const owner = ownerFor(req);
250
+ // Global + per-user session cap.
251
+ const capMsg = sessionCapacityMessage(ctx.sessions, owner);
252
+ if (capMsg)
253
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
227
254
  const body = parseBody(CreateSessionSchema, req.body);
228
255
  const workingDir = body.workingDir || process.cwd();
256
+ // Multi-user: shell mode is arbitrary command execution as the host account,
257
+ // gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
258
+ // from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
259
+ if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
260
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
261
+ }
262
+ // Multi-user linchpin (section 6.2): a non-admin's workingDir must resolve
263
+ // inside their own case space. Enforced BEFORE any disk-mutating call below so
264
+ // a foreign path can never be written into.
265
+ if (!isWorkingDirAllowed(getAuthUser(req), workingDir)) {
266
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
267
+ }
229
268
  // Validate workingDir exists and is a directory
230
269
  if (body.workingDir) {
231
270
  try {
@@ -243,15 +282,17 @@ export function registerSessionRoutes(app, ctx) {
243
282
  // For keys the caller is actively setting, strip any stale disk entry a prior
244
283
  // Codeman version may have written. Scope limited to:
245
284
  // - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json)
246
- // - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
247
- // .claude/settings.local.json in arbitrary user repos that POST /api/sessions
248
- // can target, because those may have hand-authored values).
285
+ // - workingDir inside CASES_DIR / the per-user case space (Codeman's managed
286
+ // territory — we never mutate .claude/settings.local.json in arbitrary user
287
+ // repos that POST /api/sessions can target, as those may have hand-authored
288
+ // values).
289
+ const managedCasesBase = resolveCasesDir(getAuthUser(req));
249
290
  const canStripDisk = body.mode !== 'opencode' &&
250
291
  body.mode !== 'codex' &&
251
292
  body.mode !== 'gemini' &&
252
293
  body.envOverrides &&
253
294
  Object.keys(body.envOverrides).length > 0 &&
254
- workingDir.startsWith(CASES_DIR + '/');
295
+ (workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
255
296
  if (canStripDisk) {
256
297
  await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides));
257
298
  }
@@ -344,6 +385,10 @@ export function registerSessionRoutes(app, ctx) {
344
385
  ? modelConfig?.defaultModel || undefined
345
386
  : undefined;
346
387
  const claudeModeConfig = await ctx.getClaudeModeConfig();
388
+ // Section 6.3: force non-granted users to a classifier-guarded mode.
389
+ const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
390
+ // Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
391
+ const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig } = await clampExternalCliBypassForOwner(owner, body.codexConfig, body.geminiConfig);
347
392
  const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
348
393
  const session = new Session({
349
394
  workingDir,
@@ -353,15 +398,16 @@ export function registerSessionRoutes(app, ctx) {
353
398
  useMux: true,
354
399
  niceConfig: globalNice,
355
400
  model,
356
- claudeMode: claudeModeConfig.claudeMode,
401
+ claudeMode: effectiveClaudeMode,
357
402
  allowedTools: claudeModeConfig.allowedTools,
358
403
  openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
359
- codexConfig: mode === 'codex' ? body.codexConfig : undefined,
360
- geminiConfig: mode === 'gemini' ? body.geminiConfig : undefined,
404
+ codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
405
+ geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
361
406
  resumeSessionId: validatedResumeId,
362
407
  envOverrides: body.envOverrides,
363
408
  effort: body.effort,
364
409
  tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
410
+ owner,
365
411
  });
366
412
  ctx.addSession(session);
367
413
  ctx.store.incrementSessionsCreated();
@@ -378,7 +424,7 @@ export function registerSessionRoutes(app, ctx) {
378
424
  app.put('/api/sessions/:id/name', async (req) => {
379
425
  const { id } = req.params;
380
426
  const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
381
- const session = findSessionOrFail(ctx, id);
427
+ const session = findSessionOrFail(ctx, id, req);
382
428
  const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
383
429
  session.name = name;
384
430
  // Also update the mux session name if applicable
@@ -390,7 +436,7 @@ export function registerSessionRoutes(app, ctx) {
390
436
  app.put('/api/sessions/:id/color', async (req) => {
391
437
  const { id } = req.params;
392
438
  const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
393
- const session = findSessionOrFail(ctx, id);
439
+ const session = findSessionOrFail(ctx, id, req);
394
440
  const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
395
441
  if (!validColors.includes(body.color)) {
396
442
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid color');
@@ -404,15 +450,19 @@ export function registerSessionRoutes(app, ctx) {
404
450
  const { id } = req.params;
405
451
  const query = req.query;
406
452
  const killMux = query.killMux !== 'false'; // Default to true
407
- if (!ctx.sessions.has(id)) {
408
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
409
- }
410
- await ctx.cleanupSession(id, killMux, 'user_delete');
453
+ // Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill).
454
+ const session = findSessionOrFail(ctx, id, req);
455
+ await ctx.cleanupSession(session.id, killMux, 'user_delete');
411
456
  return {};
412
457
  });
413
458
  // ========== Delete All Sessions ==========
414
- app.delete('/api/sessions', async () => {
415
- const sessionIds = Array.from(ctx.sessions.keys());
459
+ app.delete('/api/sessions', async (req) => {
460
+ // Security: scope the bulk sweep to sessions the caller can access — a non-admin
461
+ // must not wipe other users' sessions (canAccessOwned is allow-all for admin/single-user).
462
+ const user = getAuthUser(req);
463
+ const sessionIds = Array.from(ctx.sessions.values())
464
+ .filter((s) => canAccessOwned(user, s.owner))
465
+ .map((s) => s.id);
416
466
  let killed = 0;
417
467
  for (const id of sessionIds) {
418
468
  if (ctx.sessions.has(id)) {
@@ -425,7 +475,7 @@ export function registerSessionRoutes(app, ctx) {
425
475
  // ========== Get Session Detail ==========
426
476
  app.get('/api/sessions/:id', async (req) => {
427
477
  const { id } = req.params;
428
- const session = findSessionOrFail(ctx, id);
478
+ const session = findSessionOrFail(ctx, id, req);
429
479
  // Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
430
480
  // Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
431
481
  return ctx.getSessionStateWithRespawn(session);
@@ -436,7 +486,7 @@ export function registerSessionRoutes(app, ctx) {
436
486
  // ========== Get Session Output ==========
437
487
  app.get('/api/sessions/:id/output', async (req) => {
438
488
  const { id } = req.params;
439
- const session = findSessionOrFail(ctx, id);
489
+ const session = findSessionOrFail(ctx, id, req);
440
490
  return {
441
491
  success: true,
442
492
  data: {
@@ -449,7 +499,7 @@ export function registerSessionRoutes(app, ctx) {
449
499
  // ========== Get Ralph State ==========
450
500
  app.get('/api/sessions/:id/ralph-state', async (req) => {
451
501
  const { id } = req.params;
452
- const session = findSessionOrFail(ctx, id);
502
+ const session = findSessionOrFail(ctx, id, req);
453
503
  return {
454
504
  success: true,
455
505
  data: {
@@ -462,7 +512,7 @@ export function registerSessionRoutes(app, ctx) {
462
512
  // ========== Get Run Summary ==========
463
513
  app.get('/api/sessions/:id/run-summary', async (req) => {
464
514
  const { id } = req.params;
465
- const session = findSessionOrFail(ctx, id);
515
+ const session = findSessionOrFail(ctx, id, req);
466
516
  const tracker = ctx.runSummaryTrackers.get(id);
467
517
  if (!tracker) {
468
518
  // Create a fresh tracker if one doesn't exist (shouldn't happen normally)
@@ -477,7 +527,7 @@ export function registerSessionRoutes(app, ctx) {
477
527
  // ========== Get Active Tools ==========
478
528
  app.get('/api/sessions/:id/active-tools', async (req) => {
479
529
  const { id } = req.params;
480
- const session = findSessionOrFail(ctx, id);
530
+ const session = findSessionOrFail(ctx, id, req);
481
531
  return {
482
532
  success: true,
483
533
  data: {
@@ -492,7 +542,7 @@ export function registerSessionRoutes(app, ctx) {
492
542
  app.post('/api/sessions/:id/run', async (req) => {
493
543
  const { id } = req.params;
494
544
  const { prompt } = parseBody(RunPromptSchema, req.body);
495
- const session = findSessionOrFail(ctx, id);
545
+ const session = findSessionOrFail(ctx, id, req);
496
546
  if (session.isBusy()) {
497
547
  return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
498
548
  }
@@ -514,7 +564,7 @@ export function registerSessionRoutes(app, ctx) {
514
564
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
515
565
  }
516
566
  const { clearBreaker } = bodyResult.data;
517
- const session = findSessionOrFail(ctx, id);
567
+ const session = findSessionOrFail(ctx, id, req);
518
568
  if (session.isBusy()) {
519
569
  return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
520
570
  }
@@ -563,7 +613,7 @@ export function registerSessionRoutes(app, ctx) {
563
613
  // ========== Start Shell Mode ==========
564
614
  app.post('/api/sessions/:id/shell', async (req) => {
565
615
  const { id } = req.params;
566
- const session = findSessionOrFail(ctx, id);
616
+ const session = findSessionOrFail(ctx, id, req);
567
617
  if (session.isBusy()) {
568
618
  return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
569
619
  }
@@ -592,7 +642,7 @@ export function registerSessionRoutes(app, ctx) {
592
642
  app.post('/api/sessions/:id/input', async (req) => {
593
643
  const { id } = req.params;
594
644
  const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
595
- const session = findSessionOrFail(ctx, id);
645
+ const session = findSessionOrFail(ctx, id, req);
596
646
  const inputStr = String(input);
597
647
  if (inputStr.length > MAX_INPUT_LENGTH) {
598
648
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
@@ -642,7 +692,7 @@ export function registerSessionRoutes(app, ctx) {
642
692
  if (!hex) {
643
693
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
644
694
  }
645
- const session = findSessionOrFail(ctx, id);
695
+ const session = findSessionOrFail(ctx, id, req);
646
696
  const muxName = session.muxName;
647
697
  if (!muxName) {
648
698
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
@@ -669,7 +719,7 @@ export function registerSessionRoutes(app, ctx) {
669
719
  app.post('/api/sessions/:id/resize', async (req) => {
670
720
  const { id } = req.params;
671
721
  const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
672
- const session = findSessionOrFail(ctx, id);
722
+ const session = findSessionOrFail(ctx, id, req);
673
723
  session.resize(cols, rows, { viewportType, force });
674
724
  return {};
675
725
  });
@@ -755,7 +805,7 @@ export function registerSessionRoutes(app, ctx) {
755
805
  }
756
806
  app.get('/api/sessions/:id/last-response', async (req) => {
757
807
  const { id } = req.params;
758
- const session = findSessionOrFail(ctx, id);
808
+ const session = findSessionOrFail(ctx, id, req);
759
809
  // Codex sessions don't write to ~/.claude/projects — their transcripts
760
810
  // live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
761
811
  // response-viewer works for Codex panes too.
@@ -1182,7 +1232,7 @@ export function registerSessionRoutes(app, ctx) {
1182
1232
  app.get('/api/sessions/:id/terminal', async (req) => {
1183
1233
  const { id } = req.params;
1184
1234
  const query = req.query;
1185
- const session = findSessionOrFail(ctx, id);
1235
+ const session = findSessionOrFail(ctx, id, req);
1186
1236
  // `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
1187
1237
  // the page and wants the whole scroll history back, so we capture the ENTIRE
1188
1238
  // tmux scrollback and the user gets back history that scrolled off Codeman's
@@ -1301,7 +1351,7 @@ export function registerSessionRoutes(app, ctx) {
1301
1351
  app.post('/api/sessions/:id/auto-clear', async (req) => {
1302
1352
  const { id } = req.params;
1303
1353
  const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
1304
- const session = findSessionOrFail(ctx, id);
1354
+ const session = findSessionOrFail(ctx, id, req);
1305
1355
  session.setAutoClear(body.enabled, body.threshold);
1306
1356
  persistAndBroadcastSession(ctx, session);
1307
1357
  return {
@@ -1318,7 +1368,7 @@ export function registerSessionRoutes(app, ctx) {
1318
1368
  app.post('/api/sessions/:id/auto-compact', async (req) => {
1319
1369
  const { id } = req.params;
1320
1370
  const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
1321
- const session = findSessionOrFail(ctx, id);
1371
+ const session = findSessionOrFail(ctx, id, req);
1322
1372
  session.setAutoCompact(body.enabled, body.threshold, body.prompt);
1323
1373
  persistAndBroadcastSession(ctx, session);
1324
1374
  return {
@@ -1336,7 +1386,7 @@ export function registerSessionRoutes(app, ctx) {
1336
1386
  app.post('/api/sessions/:id/auto-resume', async (req) => {
1337
1387
  const { id } = req.params;
1338
1388
  const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
1339
- const session = findSessionOrFail(ctx, id);
1389
+ const session = findSessionOrFail(ctx, id, req);
1340
1390
  session.setAutoResume(body.enabled);
1341
1391
  persistAndBroadcastSession(ctx, session);
1342
1392
  return {
@@ -1353,7 +1403,7 @@ export function registerSessionRoutes(app, ctx) {
1353
1403
  app.post('/api/sessions/:id/image-watcher', async (req) => {
1354
1404
  const { id } = req.params;
1355
1405
  const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
1356
- const session = findSessionOrFail(ctx, id);
1406
+ const session = findSessionOrFail(ctx, id, req);
1357
1407
  if (body.enabled) {
1358
1408
  imageWatcher.watchSession(session.id, session.workingDir);
1359
1409
  }
@@ -1374,7 +1424,7 @@ export function registerSessionRoutes(app, ctx) {
1374
1424
  app.post('/api/sessions/:id/flicker-filter', async (req) => {
1375
1425
  const { id } = req.params;
1376
1426
  const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
1377
- const session = findSessionOrFail(ctx, id);
1427
+ const session = findSessionOrFail(ctx, id, req);
1378
1428
  session.flickerFilterEnabled = body.enabled;
1379
1429
  persistAndBroadcastSession(ctx, session);
1380
1430
  return {
@@ -1389,15 +1439,19 @@ export function registerSessionRoutes(app, ctx) {
1389
1439
  // ═══════════════════════════════════════════════════════════════
1390
1440
  // ========== Quick Run ==========
1391
1441
  app.post('/api/run', async (req) => {
1392
- // Prevent unbounded session creation
1393
- if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
1394
- return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
1395
- }
1442
+ const runOwner = ownerFor(req);
1443
+ const capMsg = sessionCapacityMessage(ctx.sessions, runOwner);
1444
+ if (capMsg)
1445
+ return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
1396
1446
  const { prompt, workingDir, envOverrides: runEnvOverrides, } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
1397
1447
  if (!prompt.trim()) {
1398
1448
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
1399
1449
  }
1400
1450
  const dir = workingDir || process.cwd();
1451
+ // Multi-user: confine a non-admin's one-shot working dir to their space.
1452
+ if (!isWorkingDirAllowed(getAuthUser(req), dir)) {
1453
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
1454
+ }
1401
1455
  // Validate workingDir exists and is a directory
1402
1456
  if (workingDir) {
1403
1457
  try {
@@ -1410,7 +1464,17 @@ export function registerSessionRoutes(app, ctx) {
1410
1464
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
1411
1465
  }
1412
1466
  }
1413
- const session = new Session({ workingDir: dir, envOverrides: runEnvOverrides });
1467
+ // Section 6.3: the one-shot spawn path (runPrompt/buildPromptArgs) respects the
1468
+ // session's claudeMode, so resolve it for the owner (bypass -> auto for non-granted).
1469
+ const runClaudeModeConfig = await ctx.getClaudeModeConfig();
1470
+ const runClaudeMode = await resolveClaudeModeForUsername(runClaudeModeConfig.claudeMode, runOwner);
1471
+ const session = new Session({
1472
+ workingDir: dir,
1473
+ envOverrides: runEnvOverrides,
1474
+ claudeMode: runClaudeMode,
1475
+ allowedTools: runClaudeModeConfig.allowedTools,
1476
+ owner: runOwner,
1477
+ });
1414
1478
  ctx.addSession(session);
1415
1479
  ctx.store.incrementSessionsCreated();
1416
1480
  ctx.persistSessionState(session);
@@ -1437,11 +1501,16 @@ export function registerSessionRoutes(app, ctx) {
1437
1501
  });
1438
1502
  // ========== Quick Start ==========
1439
1503
  app.post('/api/quick-start', async (req) => {
1440
- // Prevent unbounded session creation
1441
- if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
1442
- return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`);
1443
- }
1504
+ const owner = ownerFor(req);
1505
+ const capMsg = sessionCapacityMessage(ctx.sessions, owner);
1506
+ if (capMsg)
1507
+ return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
1444
1508
  const { caseName = 'testcase', sessionName, mode = 'claude', openCodeConfig, codexConfig, geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body);
1509
+ // Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
1510
+ // Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
1511
+ if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
1512
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
1513
+ }
1445
1514
  // Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
1446
1515
  // so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
1447
1516
  // would wrongly reject a machine that hasn't got the CLI installed locally.
@@ -1449,11 +1518,16 @@ export function registerSessionRoutes(app, ctx) {
1449
1518
  let docker = undefined;
1450
1519
  let dockerResumeId;
1451
1520
  let casePath = null;
1521
+ // Security: fold ownership INTO the match (don't early-return) so a NON-OWNED
1522
+ // same-named remote/docker case is skipped and control falls through to the caller's
1523
+ // own LOCAL case — remote/docker names are globally unique but local names are
1524
+ // per-user, so a name collision must not shadow the caller's own case. canAccessOwned
1525
+ // is allow-all for admins/single-user, so flag-OFF stays byte-identical.
1452
1526
  const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
1453
- const remoteCase = remoteCases.find((item) => item.name === caseName);
1527
+ const remoteCase = remoteCases.find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
1454
1528
  const dockerCase = remoteCase
1455
1529
  ? undefined
1456
- : (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName);
1530
+ : (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
1457
1531
  if (remoteCase) {
1458
1532
  const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
1459
1533
  if (!host)
@@ -1483,7 +1557,7 @@ export function registerSessionRoutes(app, ctx) {
1483
1557
  // Docker case: the CLI executes INSIDE a container via local tmux + `docker
1484
1558
  // exec`, so the LOCAL availability gates below don't apply. Mirror the remote
1485
1559
  // branch's rejection of per-session config that would not cross into the
1486
- // container (it would silently no-op).
1560
+ // container (it would silently no-op). (Ownership is enforced in the .find above.)
1487
1561
  const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
1488
1562
  if (!host)
1489
1563
  return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
@@ -1561,7 +1635,11 @@ export function registerSessionRoutes(app, ctx) {
1561
1635
  catch {
1562
1636
  // File missing or unparseable — treat as empty registry
1563
1637
  }
1564
- casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
1638
+ // Multi-user: the linked-cases registry is ownerless/global, so only admins may
1639
+ // resolve a name to an arbitrary linked path. A non-admin resolves inside their
1640
+ // OWN case space only (single-user: isAdmin true, so linked cases still honoured).
1641
+ const linked = isAdmin(req) ? linkedCases[caseName] : undefined;
1642
+ casePath = linked || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
1565
1643
  if (!casePath) {
1566
1644
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
1567
1645
  }
@@ -1569,6 +1647,14 @@ export function registerSessionRoutes(app, ctx) {
1569
1647
  // By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
1570
1648
  // for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
1571
1649
  const resolvedCasePath = casePath;
1650
+ // Multi-user linchpin (section 6.2): confine the resolved workingDir to the caller's
1651
+ // own case space BEFORE any mkdir/scaffold below creates or mutates it. Applies to
1652
+ // LOCAL and DOCKER cases (docker.hostWorkspacePath is a real host dir the file routes
1653
+ // trust); skipped for REMOTE, whose path is an ssh path that would spuriously fail
1654
+ // realpath confinement. No-op for admins / single-user mode.
1655
+ if (!remote && !isWorkingDirAllowed(getAuthUser(req), resolvedCasePath)) {
1656
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
1657
+ }
1572
1658
  // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
1573
1659
  // non-docker cases — docker workspaces are scaffolded in their own block below)
1574
1660
  if (!remote && !docker && !existsSync(resolvedCasePath)) {
@@ -1642,6 +1728,9 @@ export function registerSessionRoutes(app, ctx) {
1642
1728
  ? qsModelConfig?.defaultModel || undefined
1643
1729
  : undefined;
1644
1730
  const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
1731
+ const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
1732
+ // Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
1733
+ const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig);
1645
1734
  const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
1646
1735
  const session = new Session({
1647
1736
  workingDir: resolvedCasePath,
@@ -1651,11 +1740,12 @@ export function registerSessionRoutes(app, ctx) {
1651
1740
  mode: mode,
1652
1741
  niceConfig: niceConfig,
1653
1742
  model: qsModel,
1654
- claudeMode: qsClaudeModeConfig.claudeMode,
1743
+ claudeMode: qsEffectiveClaudeMode,
1655
1744
  allowedTools: qsClaudeModeConfig.allowedTools,
1745
+ owner,
1656
1746
  openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
1657
- codexConfig: mode === 'codex' ? codexConfig : undefined,
1658
- geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
1747
+ codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
1748
+ geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
1659
1749
  envOverrides,
1660
1750
  effort,
1661
1751
  remote,
@@ -1993,6 +2083,12 @@ export function registerSessionRoutes(app, ctx) {
1993
2083
  const query = req.query;
1994
2084
  const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
1995
2085
  const headBuf = Buffer.alloc(16384);
2086
+ // Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
2087
+ // must only see history whose decoded workingDir is inside their own case space.
2088
+ // Do NOT trust the caller-supplied projectKey — confine on the decoded path.
2089
+ // No-op for admins / single-user mode.
2090
+ const user = getAuthUser(req);
2091
+ const scopeHistory = isMultiUserMode() && user.role !== 'admin';
1996
2092
  // Single-folder drill-down: when projectKey is provided, scan only that
1997
2093
  // directory, bypass the 50-cap, and honor offset/limit pagination.
1998
2094
  if (query.projectKey) {
@@ -2003,12 +2099,15 @@ export function registerSessionRoutes(app, ctx) {
2003
2099
  const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
2004
2100
  const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
2005
2101
  const projPath = join(projectsDir, query.projectKey);
2006
- const all = await scanProjectDir(projPath, query.projectKey, headBuf);
2102
+ let all = await scanProjectDir(projPath, query.projectKey, headBuf);
2103
+ // Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
2104
+ if (scopeHistory)
2105
+ all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
2007
2106
  all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
2008
2107
  return { sessions: all.slice(offset, offset + limit), total: all.length };
2009
2108
  }
2010
2109
  // Global overview: scan all projects, return up to 50 most-recent sessions.
2011
- const results = [];
2110
+ let results = [];
2012
2111
  try {
2013
2112
  const projectDirs = await fs.readdir(projectsDir);
2014
2113
  for (const projDir of projectDirs) {
@@ -2020,6 +2119,9 @@ export function registerSessionRoutes(app, ctx) {
2020
2119
  catch {
2021
2120
  // Projects dir may not exist
2022
2121
  }
2122
+ // Multi-user: drop rows outside the non-admin caller's own case space.
2123
+ if (scopeHistory)
2124
+ results = results.filter((r) => isWorkingDirAllowed(user, r.workingDir));
2023
2125
  results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
2024
2126
  return { sessions: results.slice(0, 50) };
2025
2127
  });
@@ -2116,7 +2218,32 @@ export function registerSessionRoutes(app, ctx) {
2116
2218
  catch {
2117
2219
  // Mux stats are optional.
2118
2220
  }
2119
- const merged = mergeUnifiedSessions({ live, persisted, lifecycle, history, mux });
2221
+ // Multi-user: a non-admin only sees their own sessions; host-wide transcript
2222
+ // history (not tied to an owned session) is admin-only.
2223
+ let sLive = live;
2224
+ let sPersisted = persisted;
2225
+ let sLifecycle = lifecycle;
2226
+ let sHistory = history;
2227
+ const uUser = getAuthUser(req);
2228
+ if (isMultiUserMode() && uUser.role !== 'admin') {
2229
+ const ownedLive = new Set([...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id));
2230
+ const stored = ctx.store.getState().sessions;
2231
+ const ownedPersisted = new Set(Object.values(stored)
2232
+ .filter((p) => canAccessOwned(uUser, p.owner))
2233
+ .map((p) => p.id));
2234
+ const isOwned = (id) => ownedLive.has(id) || ownedPersisted.has(id);
2235
+ sLive = live.filter((l) => isOwned(l.id));
2236
+ sPersisted = persisted.filter((p) => isOwned(p.id));
2237
+ sLifecycle = lifecycle.filter((e) => isOwned(e.sessionId));
2238
+ sHistory = [];
2239
+ }
2240
+ const merged = mergeUnifiedSessions({
2241
+ live: sLive,
2242
+ persisted: sPersisted,
2243
+ lifecycle: sLifecycle,
2244
+ history: sHistory,
2245
+ mux,
2246
+ });
2120
2247
  const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
2121
2248
  const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
2122
2249
  return filterAndPaginate(merged, {
@@ -2173,7 +2300,7 @@ export function registerSessionRoutes(app, ctx) {
2173
2300
  reply.header('Retry-After', '60');
2174
2301
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
2175
2302
  }
2176
- const session = findSessionOrFail(ctx, id);
2303
+ const session = findSessionOrFail(ctx, id, req);
2177
2304
  if (!req.isMultipart()) {
2178
2305
  reply.code(400);
2179
2306
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');