aicodeman 1.4.0 → 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 (223) hide show
  1. package/README.md +36 -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/docker-hosts.d.ts +85 -5
  14. package/dist/docker-hosts.d.ts.map +1 -1
  15. package/dist/docker-hosts.js +260 -22
  16. package/dist/docker-hosts.js.map +1 -1
  17. package/dist/mux-interface.d.ts +6 -0
  18. package/dist/mux-interface.d.ts.map +1 -1
  19. package/dist/plan-orchestrator.d.ts +8 -1
  20. package/dist/plan-orchestrator.d.ts.map +1 -1
  21. package/dist/plan-orchestrator.js +20 -1
  22. package/dist/plan-orchestrator.js.map +1 -1
  23. package/dist/push-store.d.ts +17 -5
  24. package/dist/push-store.d.ts.map +1 -1
  25. package/dist/push-store.js +3 -1
  26. package/dist/push-store.js.map +1 -1
  27. package/dist/session-cli-builder.d.ts +1 -1
  28. package/dist/session-cli-builder.d.ts.map +1 -1
  29. package/dist/session-cli-builder.js +7 -2
  30. package/dist/session-cli-builder.js.map +1 -1
  31. package/dist/session.d.ts +9 -0
  32. package/dist/session.d.ts.map +1 -1
  33. package/dist/session.js +19 -2
  34. package/dist/session.js.map +1 -1
  35. package/dist/tmux-manager.d.ts +24 -2
  36. package/dist/tmux-manager.d.ts.map +1 -1
  37. package/dist/tmux-manager.js +60 -12
  38. package/dist/tmux-manager.js.map +1 -1
  39. package/dist/tunnel-manager.d.ts +17 -0
  40. package/dist/tunnel-manager.d.ts.map +1 -1
  41. package/dist/tunnel-manager.js +48 -8
  42. package/dist/tunnel-manager.js.map +1 -1
  43. package/dist/types/api.d.ts +10 -0
  44. package/dist/types/api.d.ts.map +1 -1
  45. package/dist/types/api.js +20 -0
  46. package/dist/types/api.js.map +1 -1
  47. package/dist/types/cron.d.ts +2 -0
  48. package/dist/types/cron.d.ts.map +1 -1
  49. package/dist/types/index.d.ts +1 -0
  50. package/dist/types/index.d.ts.map +1 -1
  51. package/dist/types/index.js +1 -0
  52. package/dist/types/index.js.map +1 -1
  53. package/dist/types/session.d.ts +8 -1
  54. package/dist/types/session.d.ts.map +1 -1
  55. package/dist/types/session.js.map +1 -1
  56. package/dist/types/user.d.ts +59 -0
  57. package/dist/types/user.d.ts.map +1 -0
  58. package/dist/types/user.js +12 -0
  59. package/dist/types/user.js.map +1 -0
  60. package/dist/user-store.d.ts +139 -0
  61. package/dist/user-store.d.ts.map +1 -0
  62. package/dist/user-store.js +433 -0
  63. package/dist/user-store.js.map +1 -0
  64. package/dist/web/admin-audit.d.ts +17 -0
  65. package/dist/web/admin-audit.d.ts.map +1 -0
  66. package/dist/web/admin-audit.js +19 -0
  67. package/dist/web/admin-audit.js.map +1 -0
  68. package/dist/web/middleware/auth.d.ts +8 -0
  69. package/dist/web/middleware/auth.d.ts.map +1 -1
  70. package/dist/web/middleware/auth.js +250 -51
  71. package/dist/web/middleware/auth.js.map +1 -1
  72. package/dist/web/ports/auth-port.d.ts +13 -0
  73. package/dist/web/ports/auth-port.d.ts.map +1 -1
  74. package/dist/web/ports/config-port.d.ts +4 -1
  75. package/dist/web/ports/config-port.d.ts.map +1 -1
  76. package/dist/web/ports/infra-port.d.ts +4 -1
  77. package/dist/web/ports/infra-port.d.ts.map +1 -1
  78. package/dist/web/public/admin-ui.js +260 -0
  79. package/dist/web/public/admin-ui.js.br +0 -0
  80. package/dist/web/public/admin-ui.js.gz +0 -0
  81. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  82. package/dist/web/public/{app.07e61fcf.js → app.6c4b9893.js} +1 -1
  83. package/dist/web/public/app.6c4b9893.js.br +0 -0
  84. package/dist/web/public/app.6c4b9893.js.gz +0 -0
  85. package/dist/web/public/{constants.503109fa.js → constants.5ce97313.js} +7 -0
  86. package/dist/web/public/constants.5ce97313.js.br +0 -0
  87. package/dist/web/public/{constants.503109fa.js.gz → constants.5ce97313.js.gz} +0 -0
  88. package/dist/web/public/cron-ui.js.gz +0 -0
  89. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  90. package/dist/web/public/index.html +24 -14
  91. package/dist/web/public/index.html.br +0 -0
  92. package/dist/web/public/index.html.gz +0 -0
  93. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  94. package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
  95. package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
  96. package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
  97. package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
  98. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  99. package/dist/web/public/{panels-ui.ca03853f.js → panels-ui.3c9b0465.js} +53 -53
  100. package/dist/web/public/panels-ui.3c9b0465.js.br +0 -0
  101. package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
  102. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  103. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  104. package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
  105. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  106. package/dist/web/public/session-ui.4851c61a.js +58 -0
  107. package/dist/web/public/session-ui.4851c61a.js.br +0 -0
  108. package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
  109. package/dist/web/public/settings-ui.29214cf1.js +67 -0
  110. package/dist/web/public/settings-ui.29214cf1.js.br +0 -0
  111. package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
  112. package/dist/web/public/styles.9e6cdb9d.css +1 -0
  113. package/dist/web/public/styles.9e6cdb9d.css.br +0 -0
  114. package/dist/web/public/styles.9e6cdb9d.css.gz +0 -0
  115. package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
  116. package/dist/web/public/sw.js.gz +0 -0
  117. package/dist/web/public/terminal-ui.efda6083.js.gz +0 -0
  118. package/dist/web/public/ultracode-panel.js.gz +0 -0
  119. package/dist/web/public/ultracode-windows.js.gz +0 -0
  120. package/dist/web/public/upload.html.gz +0 -0
  121. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  122. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  123. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  124. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  125. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  126. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  127. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  128. package/dist/web/public/vendor/xterm.css.gz +0 -0
  129. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  130. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  131. package/dist/web/route-helpers.d.ts +78 -1
  132. package/dist/web/route-helpers.d.ts.map +1 -1
  133. package/dist/web/route-helpers.js +172 -3
  134. package/dist/web/route-helpers.js.map +1 -1
  135. package/dist/web/routes/admin-routes.d.ts +20 -0
  136. package/dist/web/routes/admin-routes.d.ts.map +1 -0
  137. package/dist/web/routes/admin-routes.js +193 -0
  138. package/dist/web/routes/admin-routes.js.map +1 -0
  139. package/dist/web/routes/case-routes.d.ts.map +1 -1
  140. package/dist/web/routes/case-routes.js +202 -74
  141. package/dist/web/routes/case-routes.js.map +1 -1
  142. package/dist/web/routes/clipboard-routes.d.ts +2 -2
  143. package/dist/web/routes/clipboard-routes.d.ts.map +1 -1
  144. package/dist/web/routes/clipboard-routes.js +10 -0
  145. package/dist/web/routes/clipboard-routes.js.map +1 -1
  146. package/dist/web/routes/cron-routes.d.ts.map +1 -1
  147. package/dist/web/routes/cron-routes.js +57 -9
  148. package/dist/web/routes/cron-routes.js.map +1 -1
  149. package/dist/web/routes/file-routes.d.ts.map +1 -1
  150. package/dist/web/routes/file-routes.js +26 -20
  151. package/dist/web/routes/file-routes.js.map +1 -1
  152. package/dist/web/routes/index.d.ts +2 -0
  153. package/dist/web/routes/index.d.ts.map +1 -1
  154. package/dist/web/routes/index.js +2 -0
  155. package/dist/web/routes/index.js.map +1 -1
  156. package/dist/web/routes/me-routes.d.ts +16 -0
  157. package/dist/web/routes/me-routes.d.ts.map +1 -0
  158. package/dist/web/routes/me-routes.js +73 -0
  159. package/dist/web/routes/me-routes.js.map +1 -0
  160. package/dist/web/routes/mux-routes.d.ts.map +1 -1
  161. package/dist/web/routes/mux-routes.js +23 -5
  162. package/dist/web/routes/mux-routes.js.map +1 -1
  163. package/dist/web/routes/orchestrator-routes.d.ts.map +1 -1
  164. package/dist/web/routes/orchestrator-routes.js +43 -11
  165. package/dist/web/routes/orchestrator-routes.js.map +1 -1
  166. package/dist/web/routes/plan-routes.d.ts.map +1 -1
  167. package/dist/web/routes/plan-routes.js +28 -9
  168. package/dist/web/routes/plan-routes.js.map +1 -1
  169. package/dist/web/routes/push-routes.d.ts.map +1 -1
  170. package/dist/web/routes/push-routes.js +4 -0
  171. package/dist/web/routes/push-routes.js.map +1 -1
  172. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  173. package/dist/web/routes/ralph-routes.js +21 -17
  174. package/dist/web/routes/ralph-routes.js.map +1 -1
  175. package/dist/web/routes/respawn-routes.d.ts.map +1 -1
  176. package/dist/web/routes/respawn-routes.js +23 -14
  177. package/dist/web/routes/respawn-routes.js.map +1 -1
  178. package/dist/web/routes/scheduled-routes.d.ts.map +1 -1
  179. package/dist/web/routes/scheduled-routes.js +22 -6
  180. package/dist/web/routes/scheduled-routes.js.map +1 -1
  181. package/dist/web/routes/search-routes.d.ts.map +1 -1
  182. package/dist/web/routes/search-routes.js +9 -3
  183. package/dist/web/routes/search-routes.js.map +1 -1
  184. package/dist/web/routes/session-routes.d.ts.map +1 -1
  185. package/dist/web/routes/session-routes.js +209 -65
  186. package/dist/web/routes/session-routes.js.map +1 -1
  187. package/dist/web/routes/system-routes.d.ts.map +1 -1
  188. package/dist/web/routes/system-routes.js +108 -23
  189. package/dist/web/routes/system-routes.js.map +1 -1
  190. package/dist/web/routes/ws-routes.d.ts.map +1 -1
  191. package/dist/web/routes/ws-routes.js +11 -0
  192. package/dist/web/routes/ws-routes.js.map +1 -1
  193. package/dist/web/schemas.d.ts +1 -0
  194. package/dist/web/schemas.d.ts.map +1 -1
  195. package/dist/web/schemas.js +3 -0
  196. package/dist/web/schemas.js.map +1 -1
  197. package/dist/web/server.d.ts +12 -0
  198. package/dist/web/server.d.ts.map +1 -1
  199. package/dist/web/server.js +187 -11
  200. package/dist/web/server.js.map +1 -1
  201. package/dist/web/sse-events.d.ts +18 -0
  202. package/dist/web/sse-events.d.ts.map +1 -1
  203. package/dist/web/sse-events.js +19 -0
  204. package/dist/web/sse-events.js.map +1 -1
  205. package/dist/web/sse-stream-manager.d.ts +32 -2
  206. package/dist/web/sse-stream-manager.d.ts.map +1 -1
  207. package/dist/web/sse-stream-manager.js +51 -4
  208. package/dist/web/sse-stream-manager.js.map +1 -1
  209. package/package.json +1 -1
  210. package/dist/web/public/app.07e61fcf.js.br +0 -0
  211. package/dist/web/public/app.07e61fcf.js.gz +0 -0
  212. package/dist/web/public/constants.503109fa.js.br +0 -0
  213. package/dist/web/public/panels-ui.ca03853f.js.br +0 -0
  214. package/dist/web/public/panels-ui.ca03853f.js.gz +0 -0
  215. package/dist/web/public/session-ui.9da1b71f.js +0 -58
  216. package/dist/web/public/session-ui.9da1b71f.js.br +0 -0
  217. package/dist/web/public/session-ui.9da1b71f.js.gz +0 -0
  218. package/dist/web/public/settings-ui.4acbe789.js +0 -67
  219. package/dist/web/public/settings-ui.4acbe789.js.br +0 -0
  220. package/dist/web/public/settings-ui.4acbe789.js.gz +0 -0
  221. package/dist/web/public/styles.546ddae4.css +0 -1
  222. package/dist/web/public/styles.546ddae4.css.br +0 -0
  223. package/dist/web/public/styles.546ddae4.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,13 +23,12 @@ 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';
28
29
  import { dataPath, getDataDir } from '../../config/instance.js';
29
30
  import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
30
- import { checkDockerAvailable, checkDockerTmuxAvailable, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
31
+ import { checkDockerAvailable, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
31
32
  import { LRUMap } from '../../utils/lru-map.js';
32
33
  // Path to linked-cases registry (same file used by case-routes resolveCasePath)
33
34
  const LINKED_CASES_FILE = dataPath('linked-cases.json');
@@ -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.`);
1504
+ const owner = ownerFor(req);
1505
+ const capMsg = sessionCapacityMessage(ctx.sessions, owner);
1506
+ if (capMsg)
1507
+ return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
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');
1443
1513
  }
1444
- const { caseName = 'testcase', mode = 'claude', openCodeConfig, codexConfig, geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body);
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');
@@ -1499,10 +1573,26 @@ export function registerSessionRoutes(app, ctx) {
1499
1573
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, availability.error || 'docker daemon is not available');
1500
1574
  }
1501
1575
  const sessionDocker = toSessionDocker(host, dockerCase);
1576
+ // Ensure the base image exists, auto-building the default image on first use so
1577
+ // it is never a blocker. Dedup'd with any build kicked off at case-create, so
1578
+ // this awaits the SAME in-flight build rather than starting a second one.
1579
+ const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
1580
+ onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
1581
+ });
1582
+ if (!ensured.ok) {
1583
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
1584
+ }
1585
+ if (ensured.built) {
1586
+ ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
1587
+ }
1502
1588
  // tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
1503
- const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
1504
- if (!tmuxCheck.ok) {
1505
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
1589
+ // Skip the extra container-run probe for our OWN default image (the baked
1590
+ // Dockerfile always contains tmux); still verify a custom image.
1591
+ if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
1592
+ const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
1593
+ if (!tmuxCheck.ok) {
1594
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
1595
+ }
1506
1596
  }
1507
1597
  casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
1508
1598
  docker = sessionDocker;
@@ -1545,7 +1635,11 @@ export function registerSessionRoutes(app, ctx) {
1545
1635
  catch {
1546
1636
  // File missing or unparseable — treat as empty registry
1547
1637
  }
1548
- 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)));
1549
1643
  if (!casePath) {
1550
1644
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
1551
1645
  }
@@ -1553,6 +1647,14 @@ export function registerSessionRoutes(app, ctx) {
1553
1647
  // By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
1554
1648
  // for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
1555
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
+ }
1556
1658
  // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
1557
1659
  // non-docker cases — docker workspaces are scaffolded in their own block below)
1558
1660
  if (!remote && !docker && !existsSync(resolvedCasePath)) {
@@ -1626,19 +1728,24 @@ export function registerSessionRoutes(app, ctx) {
1626
1728
  ? qsModelConfig?.defaultModel || undefined
1627
1729
  : undefined;
1628
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);
1629
1734
  const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
1630
1735
  const session = new Session({
1631
1736
  workingDir: resolvedCasePath,
1737
+ name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
1632
1738
  mux: ctx.mux,
1633
1739
  useMux: true,
1634
1740
  mode: mode,
1635
1741
  niceConfig: niceConfig,
1636
1742
  model: qsModel,
1637
- claudeMode: qsClaudeModeConfig.claudeMode,
1743
+ claudeMode: qsEffectiveClaudeMode,
1638
1744
  allowedTools: qsClaudeModeConfig.allowedTools,
1745
+ owner,
1639
1746
  openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
1640
- codexConfig: mode === 'codex' ? codexConfig : undefined,
1641
- geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
1747
+ codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
1748
+ geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
1642
1749
  envOverrides,
1643
1750
  effort,
1644
1751
  remote,
@@ -1976,6 +2083,12 @@ export function registerSessionRoutes(app, ctx) {
1976
2083
  const query = req.query;
1977
2084
  const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
1978
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';
1979
2092
  // Single-folder drill-down: when projectKey is provided, scan only that
1980
2093
  // directory, bypass the 50-cap, and honor offset/limit pagination.
1981
2094
  if (query.projectKey) {
@@ -1986,12 +2099,15 @@ export function registerSessionRoutes(app, ctx) {
1986
2099
  const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
1987
2100
  const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
1988
2101
  const projPath = join(projectsDir, query.projectKey);
1989
- 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));
1990
2106
  all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
1991
2107
  return { sessions: all.slice(offset, offset + limit), total: all.length };
1992
2108
  }
1993
2109
  // Global overview: scan all projects, return up to 50 most-recent sessions.
1994
- const results = [];
2110
+ let results = [];
1995
2111
  try {
1996
2112
  const projectDirs = await fs.readdir(projectsDir);
1997
2113
  for (const projDir of projectDirs) {
@@ -2003,6 +2119,9 @@ export function registerSessionRoutes(app, ctx) {
2003
2119
  catch {
2004
2120
  // Projects dir may not exist
2005
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));
2006
2125
  results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
2007
2126
  return { sessions: results.slice(0, 50) };
2008
2127
  });
@@ -2099,7 +2218,32 @@ export function registerSessionRoutes(app, ctx) {
2099
2218
  catch {
2100
2219
  // Mux stats are optional.
2101
2220
  }
2102
- 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
+ });
2103
2247
  const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
2104
2248
  const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
2105
2249
  return filterAndPaginate(merged, {
@@ -2156,7 +2300,7 @@ export function registerSessionRoutes(app, ctx) {
2156
2300
  reply.header('Retry-After', '60');
2157
2301
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
2158
2302
  }
2159
- const session = findSessionOrFail(ctx, id);
2303
+ const session = findSessionOrFail(ctx, id, req);
2160
2304
  if (!req.isMultipart()) {
2161
2305
  reply.code(400);
2162
2306
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');