aicodeman 1.4.1 → 1.5.1

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 (229) hide show
  1. package/README.md +30 -8
  2. package/README.zh-CN.md +289 -92
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +168 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/config/multiuser.d.ts +38 -0
  7. package/dist/config/multiuser.d.ts.map +1 -0
  8. package/dist/config/multiuser.js +58 -0
  9. package/dist/config/multiuser.js.map +1 -0
  10. package/dist/cron/cron-service.d.ts +1 -1
  11. package/dist/cron/cron-service.d.ts.map +1 -1
  12. package/dist/cron/cron-service.js +31 -4
  13. package/dist/cron/cron-service.js.map +1 -1
  14. package/dist/docker-export.d.ts +13 -0
  15. package/dist/docker-export.d.ts.map +1 -1
  16. package/dist/docker-export.js +55 -8
  17. package/dist/docker-export.js.map +1 -1
  18. package/dist/docker-hosts.d.ts +36 -5
  19. package/dist/docker-hosts.d.ts.map +1 -1
  20. package/dist/docker-hosts.js +90 -16
  21. package/dist/docker-hosts.js.map +1 -1
  22. package/dist/mux-interface.d.ts +6 -0
  23. package/dist/mux-interface.d.ts.map +1 -1
  24. package/dist/plan-orchestrator.d.ts +8 -1
  25. package/dist/plan-orchestrator.d.ts.map +1 -1
  26. package/dist/plan-orchestrator.js +20 -1
  27. package/dist/plan-orchestrator.js.map +1 -1
  28. package/dist/push-store.d.ts +17 -5
  29. package/dist/push-store.d.ts.map +1 -1
  30. package/dist/push-store.js +3 -1
  31. package/dist/push-store.js.map +1 -1
  32. package/dist/session-cli-builder.d.ts +1 -1
  33. package/dist/session-cli-builder.d.ts.map +1 -1
  34. package/dist/session-cli-builder.js +7 -2
  35. package/dist/session-cli-builder.js.map +1 -1
  36. package/dist/session.d.ts +9 -0
  37. package/dist/session.d.ts.map +1 -1
  38. package/dist/session.js +19 -2
  39. package/dist/session.js.map +1 -1
  40. package/dist/tmux-manager.d.ts +15 -1
  41. package/dist/tmux-manager.d.ts.map +1 -1
  42. package/dist/tmux-manager.js +55 -12
  43. package/dist/tmux-manager.js.map +1 -1
  44. package/dist/tunnel-manager.d.ts +17 -0
  45. package/dist/tunnel-manager.d.ts.map +1 -1
  46. package/dist/tunnel-manager.js +48 -8
  47. package/dist/tunnel-manager.js.map +1 -1
  48. package/dist/types/api.d.ts +10 -0
  49. package/dist/types/api.d.ts.map +1 -1
  50. package/dist/types/api.js +20 -0
  51. package/dist/types/api.js.map +1 -1
  52. package/dist/types/cron.d.ts +2 -0
  53. package/dist/types/cron.d.ts.map +1 -1
  54. package/dist/types/index.d.ts +1 -0
  55. package/dist/types/index.d.ts.map +1 -1
  56. package/dist/types/index.js +1 -0
  57. package/dist/types/index.js.map +1 -1
  58. package/dist/types/session.d.ts +9 -2
  59. package/dist/types/session.d.ts.map +1 -1
  60. package/dist/types/session.js +1 -1
  61. package/dist/types/session.js.map +1 -1
  62. package/dist/types/user.d.ts +59 -0
  63. package/dist/types/user.d.ts.map +1 -0
  64. package/dist/types/user.js +12 -0
  65. package/dist/types/user.js.map +1 -0
  66. package/dist/user-store.d.ts +139 -0
  67. package/dist/user-store.d.ts.map +1 -0
  68. package/dist/user-store.js +433 -0
  69. package/dist/user-store.js.map +1 -0
  70. package/dist/web/admin-audit.d.ts +17 -0
  71. package/dist/web/admin-audit.d.ts.map +1 -0
  72. package/dist/web/admin-audit.js +19 -0
  73. package/dist/web/admin-audit.js.map +1 -0
  74. package/dist/web/middleware/auth.d.ts +8 -0
  75. package/dist/web/middleware/auth.d.ts.map +1 -1
  76. package/dist/web/middleware/auth.js +250 -51
  77. package/dist/web/middleware/auth.js.map +1 -1
  78. package/dist/web/ports/auth-port.d.ts +13 -0
  79. package/dist/web/ports/auth-port.d.ts.map +1 -1
  80. package/dist/web/ports/config-port.d.ts +4 -1
  81. package/dist/web/ports/config-port.d.ts.map +1 -1
  82. package/dist/web/ports/infra-port.d.ts +4 -1
  83. package/dist/web/ports/infra-port.d.ts.map +1 -1
  84. package/dist/web/public/admin-ui.js +260 -0
  85. package/dist/web/public/admin-ui.js.br +0 -0
  86. package/dist/web/public/admin-ui.js.gz +0 -0
  87. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  88. package/dist/web/public/{app.6c4b9893.js → app.1ccca158.js} +1 -1
  89. package/dist/web/public/app.1ccca158.js.br +0 -0
  90. package/dist/web/public/app.1ccca158.js.gz +0 -0
  91. package/dist/web/public/{constants.48da7f03.js → constants.ad3a8031.js} +4 -0
  92. package/dist/web/public/constants.ad3a8031.js.br +0 -0
  93. package/dist/web/public/{constants.48da7f03.js.gz → constants.ad3a8031.js.gz} +0 -0
  94. package/dist/web/public/cron-ui.js.gz +0 -0
  95. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  96. package/dist/web/public/index.html +11 -9
  97. package/dist/web/public/index.html.br +0 -0
  98. package/dist/web/public/index.html.gz +0 -0
  99. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  100. package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
  101. package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
  102. package/dist/web/public/mobile.597136a6.css +1 -0
  103. package/dist/web/public/mobile.597136a6.css.br +0 -0
  104. package/dist/web/public/mobile.597136a6.css.gz +0 -0
  105. package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
  106. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  107. package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
  108. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  109. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  110. package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
  111. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  112. package/dist/web/public/session-ui.e5d4f9b5.js +60 -0
  113. package/dist/web/public/session-ui.e5d4f9b5.js.br +0 -0
  114. package/dist/web/public/session-ui.e5d4f9b5.js.gz +0 -0
  115. package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
  116. package/dist/web/public/styles.9e6cdb9d.css +1 -0
  117. package/dist/web/public/styles.9e6cdb9d.css.br +0 -0
  118. package/dist/web/public/styles.9e6cdb9d.css.gz +0 -0
  119. package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
  120. package/dist/web/public/sw.js.gz +0 -0
  121. package/dist/web/public/terminal-ui.efda6083.js.gz +0 -0
  122. package/dist/web/public/ultracode-panel.js.gz +0 -0
  123. package/dist/web/public/ultracode-windows.js.gz +0 -0
  124. package/dist/web/public/upload.html.gz +0 -0
  125. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  126. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  127. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  128. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  129. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  130. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  131. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  132. package/dist/web/public/vendor/xterm.css.gz +0 -0
  133. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  134. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  135. package/dist/web/route-helpers.d.ts +78 -1
  136. package/dist/web/route-helpers.d.ts.map +1 -1
  137. package/dist/web/route-helpers.js +172 -3
  138. package/dist/web/route-helpers.js.map +1 -1
  139. package/dist/web/routes/admin-routes.d.ts +20 -0
  140. package/dist/web/routes/admin-routes.d.ts.map +1 -0
  141. package/dist/web/routes/admin-routes.js +193 -0
  142. package/dist/web/routes/admin-routes.js.map +1 -0
  143. package/dist/web/routes/case-routes.d.ts +2 -2
  144. package/dist/web/routes/case-routes.d.ts.map +1 -1
  145. package/dist/web/routes/case-routes.js +193 -79
  146. package/dist/web/routes/case-routes.js.map +1 -1
  147. package/dist/web/routes/clipboard-routes.d.ts +2 -2
  148. package/dist/web/routes/clipboard-routes.d.ts.map +1 -1
  149. package/dist/web/routes/clipboard-routes.js +10 -0
  150. package/dist/web/routes/clipboard-routes.js.map +1 -1
  151. package/dist/web/routes/cron-routes.d.ts.map +1 -1
  152. package/dist/web/routes/cron-routes.js +57 -9
  153. package/dist/web/routes/cron-routes.js.map +1 -1
  154. package/dist/web/routes/file-routes.d.ts.map +1 -1
  155. package/dist/web/routes/file-routes.js +26 -20
  156. package/dist/web/routes/file-routes.js.map +1 -1
  157. package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
  158. package/dist/web/routes/hook-event-routes.js +9 -0
  159. package/dist/web/routes/hook-event-routes.js.map +1 -1
  160. package/dist/web/routes/index.d.ts +2 -0
  161. package/dist/web/routes/index.d.ts.map +1 -1
  162. package/dist/web/routes/index.js +2 -0
  163. package/dist/web/routes/index.js.map +1 -1
  164. package/dist/web/routes/me-routes.d.ts +16 -0
  165. package/dist/web/routes/me-routes.d.ts.map +1 -0
  166. package/dist/web/routes/me-routes.js +73 -0
  167. package/dist/web/routes/me-routes.js.map +1 -0
  168. package/dist/web/routes/mux-routes.d.ts.map +1 -1
  169. package/dist/web/routes/mux-routes.js +23 -5
  170. package/dist/web/routes/mux-routes.js.map +1 -1
  171. package/dist/web/routes/orchestrator-routes.d.ts.map +1 -1
  172. package/dist/web/routes/orchestrator-routes.js +43 -11
  173. package/dist/web/routes/orchestrator-routes.js.map +1 -1
  174. package/dist/web/routes/plan-routes.d.ts.map +1 -1
  175. package/dist/web/routes/plan-routes.js +28 -9
  176. package/dist/web/routes/plan-routes.js.map +1 -1
  177. package/dist/web/routes/push-routes.d.ts.map +1 -1
  178. package/dist/web/routes/push-routes.js +4 -0
  179. package/dist/web/routes/push-routes.js.map +1 -1
  180. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  181. package/dist/web/routes/ralph-routes.js +21 -17
  182. package/dist/web/routes/ralph-routes.js.map +1 -1
  183. package/dist/web/routes/respawn-routes.d.ts.map +1 -1
  184. package/dist/web/routes/respawn-routes.js +23 -14
  185. package/dist/web/routes/respawn-routes.js.map +1 -1
  186. package/dist/web/routes/scheduled-routes.d.ts.map +1 -1
  187. package/dist/web/routes/scheduled-routes.js +22 -6
  188. package/dist/web/routes/scheduled-routes.js.map +1 -1
  189. package/dist/web/routes/search-routes.d.ts.map +1 -1
  190. package/dist/web/routes/search-routes.js +9 -3
  191. package/dist/web/routes/search-routes.js.map +1 -1
  192. package/dist/web/routes/session-routes.d.ts.map +1 -1
  193. package/dist/web/routes/session-routes.js +219 -64
  194. package/dist/web/routes/session-routes.js.map +1 -1
  195. package/dist/web/routes/system-routes.d.ts.map +1 -1
  196. package/dist/web/routes/system-routes.js +108 -23
  197. package/dist/web/routes/system-routes.js.map +1 -1
  198. package/dist/web/routes/ws-routes.d.ts.map +1 -1
  199. package/dist/web/routes/ws-routes.js +11 -0
  200. package/dist/web/routes/ws-routes.js.map +1 -1
  201. package/dist/web/schemas.d.ts +1 -0
  202. package/dist/web/schemas.d.ts.map +1 -1
  203. package/dist/web/schemas.js +11 -0
  204. package/dist/web/schemas.js.map +1 -1
  205. package/dist/web/server.d.ts +12 -0
  206. package/dist/web/server.d.ts.map +1 -1
  207. package/dist/web/server.js +187 -11
  208. package/dist/web/server.js.map +1 -1
  209. package/dist/web/sse-events.d.ts +9 -0
  210. package/dist/web/sse-events.d.ts.map +1 -1
  211. package/dist/web/sse-events.js +10 -0
  212. package/dist/web/sse-events.js.map +1 -1
  213. package/dist/web/sse-stream-manager.d.ts +32 -2
  214. package/dist/web/sse-stream-manager.d.ts.map +1 -1
  215. package/dist/web/sse-stream-manager.js +51 -4
  216. package/dist/web/sse-stream-manager.js.map +1 -1
  217. package/package.json +1 -1
  218. package/dist/web/public/app.6c4b9893.js.br +0 -0
  219. package/dist/web/public/app.6c4b9893.js.gz +0 -0
  220. package/dist/web/public/constants.48da7f03.js.br +0 -0
  221. package/dist/web/public/mobile.0644e3ac.css +0 -1
  222. package/dist/web/public/mobile.0644e3ac.css.br +0 -0
  223. package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
  224. package/dist/web/public/session-ui.4851c61a.js +0 -58
  225. package/dist/web/public/session-ui.4851c61a.js.br +0 -0
  226. package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
  227. package/dist/web/public/styles.ff43f80f.css +0 -1
  228. package/dist/web/public/styles.ff43f80f.css.br +0 -0
  229. 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,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, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
31
+ import { checkDockerAvailable, checkDockerConfigDrift, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, persistDockerCaseClaudeSessionId, 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.
@@ -771,6 +821,10 @@ export function registerSessionRoutes(app, ctx) {
771
821
  const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
772
822
  if (activeId && activeId !== session.claudeSessionId) {
773
823
  session.adoptClaudeSessionId(activeId);
824
+ // Docker sessions: keep the case's resume seed following the live conversation.
825
+ if (session.docker) {
826
+ void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(() => { });
827
+ }
774
828
  }
775
829
  // The Claude conversation ID (used as JSONL filename)
776
830
  const claudeSessionId = session.claudeSessionId || session.id;
@@ -1182,7 +1236,7 @@ export function registerSessionRoutes(app, ctx) {
1182
1236
  app.get('/api/sessions/:id/terminal', async (req) => {
1183
1237
  const { id } = req.params;
1184
1238
  const query = req.query;
1185
- const session = findSessionOrFail(ctx, id);
1239
+ const session = findSessionOrFail(ctx, id, req);
1186
1240
  // `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
1187
1241
  // the page and wants the whole scroll history back, so we capture the ENTIRE
1188
1242
  // tmux scrollback and the user gets back history that scrolled off Codeman's
@@ -1301,7 +1355,7 @@ export function registerSessionRoutes(app, ctx) {
1301
1355
  app.post('/api/sessions/:id/auto-clear', async (req) => {
1302
1356
  const { id } = req.params;
1303
1357
  const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
1304
- const session = findSessionOrFail(ctx, id);
1358
+ const session = findSessionOrFail(ctx, id, req);
1305
1359
  session.setAutoClear(body.enabled, body.threshold);
1306
1360
  persistAndBroadcastSession(ctx, session);
1307
1361
  return {
@@ -1318,7 +1372,7 @@ export function registerSessionRoutes(app, ctx) {
1318
1372
  app.post('/api/sessions/:id/auto-compact', async (req) => {
1319
1373
  const { id } = req.params;
1320
1374
  const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
1321
- const session = findSessionOrFail(ctx, id);
1375
+ const session = findSessionOrFail(ctx, id, req);
1322
1376
  session.setAutoCompact(body.enabled, body.threshold, body.prompt);
1323
1377
  persistAndBroadcastSession(ctx, session);
1324
1378
  return {
@@ -1336,7 +1390,7 @@ export function registerSessionRoutes(app, ctx) {
1336
1390
  app.post('/api/sessions/:id/auto-resume', async (req) => {
1337
1391
  const { id } = req.params;
1338
1392
  const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
1339
- const session = findSessionOrFail(ctx, id);
1393
+ const session = findSessionOrFail(ctx, id, req);
1340
1394
  session.setAutoResume(body.enabled);
1341
1395
  persistAndBroadcastSession(ctx, session);
1342
1396
  return {
@@ -1353,7 +1407,7 @@ export function registerSessionRoutes(app, ctx) {
1353
1407
  app.post('/api/sessions/:id/image-watcher', async (req) => {
1354
1408
  const { id } = req.params;
1355
1409
  const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
1356
- const session = findSessionOrFail(ctx, id);
1410
+ const session = findSessionOrFail(ctx, id, req);
1357
1411
  if (body.enabled) {
1358
1412
  imageWatcher.watchSession(session.id, session.workingDir);
1359
1413
  }
@@ -1374,7 +1428,7 @@ export function registerSessionRoutes(app, ctx) {
1374
1428
  app.post('/api/sessions/:id/flicker-filter', async (req) => {
1375
1429
  const { id } = req.params;
1376
1430
  const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
1377
- const session = findSessionOrFail(ctx, id);
1431
+ const session = findSessionOrFail(ctx, id, req);
1378
1432
  session.flickerFilterEnabled = body.enabled;
1379
1433
  persistAndBroadcastSession(ctx, session);
1380
1434
  return {
@@ -1389,15 +1443,19 @@ export function registerSessionRoutes(app, ctx) {
1389
1443
  // ═══════════════════════════════════════════════════════════════
1390
1444
  // ========== Quick Run ==========
1391
1445
  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
- }
1446
+ const runOwner = ownerFor(req);
1447
+ const capMsg = sessionCapacityMessage(ctx.sessions, runOwner);
1448
+ if (capMsg)
1449
+ return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
1396
1450
  const { prompt, workingDir, envOverrides: runEnvOverrides, } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
1397
1451
  if (!prompt.trim()) {
1398
1452
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
1399
1453
  }
1400
1454
  const dir = workingDir || process.cwd();
1455
+ // Multi-user: confine a non-admin's one-shot working dir to their space.
1456
+ if (!isWorkingDirAllowed(getAuthUser(req), dir)) {
1457
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
1458
+ }
1401
1459
  // Validate workingDir exists and is a directory
1402
1460
  if (workingDir) {
1403
1461
  try {
@@ -1410,7 +1468,17 @@ export function registerSessionRoutes(app, ctx) {
1410
1468
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
1411
1469
  }
1412
1470
  }
1413
- const session = new Session({ workingDir: dir, envOverrides: runEnvOverrides });
1471
+ // Section 6.3: the one-shot spawn path (runPrompt/buildPromptArgs) respects the
1472
+ // session's claudeMode, so resolve it for the owner (bypass -> auto for non-granted).
1473
+ const runClaudeModeConfig = await ctx.getClaudeModeConfig();
1474
+ const runClaudeMode = await resolveClaudeModeForUsername(runClaudeModeConfig.claudeMode, runOwner);
1475
+ const session = new Session({
1476
+ workingDir: dir,
1477
+ envOverrides: runEnvOverrides,
1478
+ claudeMode: runClaudeMode,
1479
+ allowedTools: runClaudeModeConfig.allowedTools,
1480
+ owner: runOwner,
1481
+ });
1414
1482
  ctx.addSession(session);
1415
1483
  ctx.store.incrementSessionsCreated();
1416
1484
  ctx.persistSessionState(session);
@@ -1437,11 +1505,16 @@ export function registerSessionRoutes(app, ctx) {
1437
1505
  });
1438
1506
  // ========== Quick Start ==========
1439
1507
  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.`);
1508
+ const owner = ownerFor(req);
1509
+ const capMsg = sessionCapacityMessage(ctx.sessions, owner);
1510
+ if (capMsg)
1511
+ return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
1512
+ const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body);
1513
+ // Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
1514
+ // Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
1515
+ if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
1516
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
1443
1517
  }
1444
- const { caseName = 'testcase', sessionName, mode = 'claude', openCodeConfig, codexConfig, geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body);
1445
1518
  // Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
1446
1519
  // so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
1447
1520
  // would wrongly reject a machine that hasn't got the CLI installed locally.
@@ -1449,11 +1522,16 @@ export function registerSessionRoutes(app, ctx) {
1449
1522
  let docker = undefined;
1450
1523
  let dockerResumeId;
1451
1524
  let casePath = null;
1525
+ // Security: fold ownership INTO the match (don't early-return) so a NON-OWNED
1526
+ // same-named remote/docker case is skipped and control falls through to the caller's
1527
+ // own LOCAL case — remote/docker names are globally unique but local names are
1528
+ // per-user, so a name collision must not shadow the caller's own case. canAccessOwned
1529
+ // is allow-all for admins/single-user, so flag-OFF stays byte-identical.
1452
1530
  const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
1453
- const remoteCase = remoteCases.find((item) => item.name === caseName);
1531
+ const remoteCase = remoteCases.find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
1454
1532
  const dockerCase = remoteCase
1455
1533
  ? undefined
1456
- : (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName);
1534
+ : (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
1457
1535
  if (remoteCase) {
1458
1536
  const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
1459
1537
  if (!host)
@@ -1464,10 +1542,11 @@ export function registerSessionRoutes(app, ctx) {
1464
1542
  // remote command/env customization goes through the per-host command override.
1465
1543
  if ((envOverrides && Object.keys(envOverrides).length > 0) ||
1466
1544
  effort ||
1545
+ modelOverride !== undefined ||
1467
1546
  codexConfig ||
1468
1547
  geminiConfig ||
1469
1548
  openCodeConfig) {
1470
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.');
1549
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.');
1471
1550
  }
1472
1551
  // tmux is a hard prerequisite on the remote host (the agent runs inside a remote
1473
1552
  // tmux server so it survives ssh drops). Probe before spawning so a missing tmux
@@ -1483,7 +1562,7 @@ export function registerSessionRoutes(app, ctx) {
1483
1562
  // Docker case: the CLI executes INSIDE a container via local tmux + `docker
1484
1563
  // exec`, so the LOCAL availability gates below don't apply. Mirror the remote
1485
1564
  // branch's rejection of per-session config that would not cross into the
1486
- // container (it would silently no-op).
1565
+ // container (it would silently no-op). (Ownership is enforced in the .find above.)
1487
1566
  const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
1488
1567
  if (!host)
1489
1568
  return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
@@ -1502,7 +1581,7 @@ export function registerSessionRoutes(app, ctx) {
1502
1581
  // Ensure the base image exists, auto-building the default image on first use so
1503
1582
  // it is never a blocker. Dedup'd with any build kicked off at case-create, so
1504
1583
  // this awaits the SAME in-flight build rather than starting a second one.
1505
- const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
1584
+ const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
1506
1585
  onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
1507
1586
  });
1508
1587
  if (!ensured.ok) {
@@ -1520,6 +1599,15 @@ export function registerSessionRoutes(app, ctx) {
1520
1599
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
1521
1600
  }
1522
1601
  }
1602
+ // Config drift (docs/docker-cases-plan.md §4): the desired create-config no
1603
+ // longer matches the existing container's codeman.confighash label. Refuse to
1604
+ // silently launch into the stale container — the frontend confirms a recreate
1605
+ // (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
1606
+ // mounts and the conversation resumes), or the user reverts the host edit.
1607
+ const drift = await checkDockerConfigDrift(sessionDocker);
1608
+ if (drift.exists && drift.drifted) {
1609
+ return createErrorResponse(ApiErrorCode.CONFLICT, `Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`);
1610
+ }
1523
1611
  casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
1524
1612
  docker = sessionDocker;
1525
1613
  // Seed resume so a relaunch resumes the case's last conversation from the
@@ -1561,7 +1649,11 @@ export function registerSessionRoutes(app, ctx) {
1561
1649
  catch {
1562
1650
  // File missing or unparseable — treat as empty registry
1563
1651
  }
1564
- casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
1652
+ // Multi-user: the linked-cases registry is ownerless/global, so only admins may
1653
+ // resolve a name to an arbitrary linked path. A non-admin resolves inside their
1654
+ // OWN case space only (single-user: isAdmin true, so linked cases still honoured).
1655
+ const linked = isAdmin(req) ? linkedCases[caseName] : undefined;
1656
+ casePath = linked || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
1565
1657
  if (!casePath) {
1566
1658
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
1567
1659
  }
@@ -1569,6 +1661,14 @@ export function registerSessionRoutes(app, ctx) {
1569
1661
  // By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
1570
1662
  // for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
1571
1663
  const resolvedCasePath = casePath;
1664
+ // Multi-user linchpin (section 6.2): confine the resolved workingDir to the caller's
1665
+ // own case space BEFORE any mkdir/scaffold below creates or mutates it. Applies to
1666
+ // LOCAL and DOCKER cases (docker.hostWorkspacePath is a real host dir the file routes
1667
+ // trust); skipped for REMOTE, whose path is an ssh path that would spuriously fail
1668
+ // realpath confinement. No-op for admins / single-user mode.
1669
+ if (!remote && !isWorkingDirAllowed(getAuthUser(req), resolvedCasePath)) {
1670
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
1671
+ }
1572
1672
  // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
1573
1673
  // non-docker cases — docker workspaces are scaffolded in their own block below)
1574
1674
  if (!remote && !docker && !existsSync(resolvedCasePath)) {
@@ -1618,6 +1718,12 @@ export function registerSessionRoutes(app, ctx) {
1618
1718
  /* non-fatal — the session still runs, hooks may be degraded */
1619
1719
  }
1620
1720
  }
1721
+ // Model override → <case>/.claude/settings.local.json (claude-mode; local AND
1722
+ // docker — the docker workspace is a real host dir, so the settings file crosses
1723
+ // the bind mount and the in-container claude reads it). Remote was rejected above.
1724
+ if (mode === 'claude' && modelOverride !== undefined) {
1725
+ await updateCaseModel(resolvedCasePath, modelOverride || null);
1726
+ }
1621
1727
  // Strip stale disk entries for keys this request is actively setting (Claude only —
1622
1728
  // see POST /api/sessions for full rationale).
1623
1729
  if (mode !== 'opencode' &&
@@ -1642,6 +1748,9 @@ export function registerSessionRoutes(app, ctx) {
1642
1748
  ? qsModelConfig?.defaultModel || undefined
1643
1749
  : undefined;
1644
1750
  const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
1751
+ const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
1752
+ // Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
1753
+ const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig);
1645
1754
  const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
1646
1755
  const session = new Session({
1647
1756
  workingDir: resolvedCasePath,
@@ -1651,11 +1760,12 @@ export function registerSessionRoutes(app, ctx) {
1651
1760
  mode: mode,
1652
1761
  niceConfig: niceConfig,
1653
1762
  model: qsModel,
1654
- claudeMode: qsClaudeModeConfig.claudeMode,
1763
+ claudeMode: qsEffectiveClaudeMode,
1655
1764
  allowedTools: qsClaudeModeConfig.allowedTools,
1765
+ owner,
1656
1766
  openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
1657
- codexConfig: mode === 'codex' ? codexConfig : undefined,
1658
- geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
1767
+ codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
1768
+ geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
1659
1769
  envOverrides,
1660
1770
  effort,
1661
1771
  remote,
@@ -1707,6 +1817,14 @@ export function registerSessionRoutes(app, ctx) {
1707
1817
  ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
1708
1818
  }
1709
1819
  ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
1820
+ // Docker + claude: the pane command pins the conversation id (--session-id /
1821
+ // --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
1822
+ // NOW — a later container stop/reboot relaunch resumes this conversation even
1823
+ // if no in-container hook ever reaches the host (loopback bind, no bridge
1824
+ // listener). Hook/last-response adoption updates it again after /clear.
1825
+ if (docker && mode === 'claude') {
1826
+ void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, docker.containerName, session.claudeSessionId || session.id).catch(() => { });
1827
+ }
1710
1828
  // Save lastUsedCase to settings for TUI/web sync
1711
1829
  try {
1712
1830
  const settingsFilePath = SETTINGS_PATH;
@@ -1993,6 +2111,12 @@ export function registerSessionRoutes(app, ctx) {
1993
2111
  const query = req.query;
1994
2112
  const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
1995
2113
  const headBuf = Buffer.alloc(16384);
2114
+ // Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
2115
+ // must only see history whose decoded workingDir is inside their own case space.
2116
+ // Do NOT trust the caller-supplied projectKey — confine on the decoded path.
2117
+ // No-op for admins / single-user mode.
2118
+ const user = getAuthUser(req);
2119
+ const scopeHistory = isMultiUserMode() && user.role !== 'admin';
1996
2120
  // Single-folder drill-down: when projectKey is provided, scan only that
1997
2121
  // directory, bypass the 50-cap, and honor offset/limit pagination.
1998
2122
  if (query.projectKey) {
@@ -2003,12 +2127,15 @@ export function registerSessionRoutes(app, ctx) {
2003
2127
  const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
2004
2128
  const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
2005
2129
  const projPath = join(projectsDir, query.projectKey);
2006
- const all = await scanProjectDir(projPath, query.projectKey, headBuf);
2130
+ let all = await scanProjectDir(projPath, query.projectKey, headBuf);
2131
+ // Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
2132
+ if (scopeHistory)
2133
+ all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
2007
2134
  all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
2008
2135
  return { sessions: all.slice(offset, offset + limit), total: all.length };
2009
2136
  }
2010
2137
  // Global overview: scan all projects, return up to 50 most-recent sessions.
2011
- const results = [];
2138
+ let results = [];
2012
2139
  try {
2013
2140
  const projectDirs = await fs.readdir(projectsDir);
2014
2141
  for (const projDir of projectDirs) {
@@ -2020,6 +2147,9 @@ export function registerSessionRoutes(app, ctx) {
2020
2147
  catch {
2021
2148
  // Projects dir may not exist
2022
2149
  }
2150
+ // Multi-user: drop rows outside the non-admin caller's own case space.
2151
+ if (scopeHistory)
2152
+ results = results.filter((r) => isWorkingDirAllowed(user, r.workingDir));
2023
2153
  results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
2024
2154
  return { sessions: results.slice(0, 50) };
2025
2155
  });
@@ -2116,7 +2246,32 @@ export function registerSessionRoutes(app, ctx) {
2116
2246
  catch {
2117
2247
  // Mux stats are optional.
2118
2248
  }
2119
- const merged = mergeUnifiedSessions({ live, persisted, lifecycle, history, mux });
2249
+ // Multi-user: a non-admin only sees their own sessions; host-wide transcript
2250
+ // history (not tied to an owned session) is admin-only.
2251
+ let sLive = live;
2252
+ let sPersisted = persisted;
2253
+ let sLifecycle = lifecycle;
2254
+ let sHistory = history;
2255
+ const uUser = getAuthUser(req);
2256
+ if (isMultiUserMode() && uUser.role !== 'admin') {
2257
+ const ownedLive = new Set([...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id));
2258
+ const stored = ctx.store.getState().sessions;
2259
+ const ownedPersisted = new Set(Object.values(stored)
2260
+ .filter((p) => canAccessOwned(uUser, p.owner))
2261
+ .map((p) => p.id));
2262
+ const isOwned = (id) => ownedLive.has(id) || ownedPersisted.has(id);
2263
+ sLive = live.filter((l) => isOwned(l.id));
2264
+ sPersisted = persisted.filter((p) => isOwned(p.id));
2265
+ sLifecycle = lifecycle.filter((e) => isOwned(e.sessionId));
2266
+ sHistory = [];
2267
+ }
2268
+ const merged = mergeUnifiedSessions({
2269
+ live: sLive,
2270
+ persisted: sPersisted,
2271
+ lifecycle: sLifecycle,
2272
+ history: sHistory,
2273
+ mux,
2274
+ });
2120
2275
  const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
2121
2276
  const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
2122
2277
  return filterAndPaginate(merged, {
@@ -2173,7 +2328,7 @@ export function registerSessionRoutes(app, ctx) {
2173
2328
  reply.header('Retry-After', '60');
2174
2329
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
2175
2330
  }
2176
- const session = findSessionOrFail(ctx, id);
2331
+ const session = findSessionOrFail(ctx, id, req);
2177
2332
  if (!req.isMultipart()) {
2178
2333
  reply.code(400);
2179
2334
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');