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
@@ -14,10 +14,11 @@ import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema, RemoteCaseLinkSchema
14
14
  import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js';
15
15
  import { generateClaudeMd } from '../../templates/claude-md.js';
16
16
  import { writeHooksConfig } from '../../hooks-config.js';
17
- import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
17
+ import { canAccessOwned, getAuthUser, isAdmin, isWorkingDirAllowed, ownerFor, resolveCasesDir, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig, } from '../route-helpers.js';
18
+ import { isMultiUserMode } from '../../config/multiuser.js';
18
19
  import { SseEvent } from '../sse-events.js';
19
20
  import { dataPath, getDataDir } from '../../config/instance.js';
20
- import { checkDockerAvailable, checkDockerTmuxAvailable, DEFAULT_AGENT_IMAGE, dockerContainerName, dockerDisplayPath, readDockerCases, readDockerHosts, toSessionDocker, writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js';
21
+ import { checkDockerAvailable, checkDockerImagePresent, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, dockerContainerName, dockerDisplayPath, readDockerCases, readDockerHosts, toSessionDocker, writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js';
21
22
  import { buildDockerRemoveCommand } from '../../tmux-manager.js';
22
23
  import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, remoteDisplayPath, writeRemoteCases, writeRemoteHosts, } from '../../remote-hosts.js';
23
24
  const LINKED_CASES_FILE = dataPath('linked-cases.json');
@@ -40,58 +41,105 @@ const APP_VERSION = (() => {
40
41
  async function readLinkedCases() {
41
42
  return readJsonConfig(LINKED_CASES_FILE, 'linked cases', {});
42
43
  }
43
- /** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
44
- async function resolveCasePath(name) {
44
+ /**
45
+ * Resolve a case name to its directory path, checking linked cases first, then the
46
+ * user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise).
47
+ */
48
+ async function resolveCasePath(name, user) {
45
49
  const linkedCases = await readLinkedCases();
46
- if (linkedCases[name])
50
+ // Linked cases carry no owner (legacy/admin-only registry): a non-admin must not
51
+ // resolve arbitrary linked paths by name in multi-user mode (path-escape guard).
52
+ if (linkedCases[name] && (!isMultiUserMode() || user?.role === 'admin'))
47
53
  return linkedCases[name];
48
- return join(CASES_DIR, name);
54
+ return join(resolveCasesDir(user), name);
55
+ }
56
+ /**
57
+ * Gate a docker case on its base image, AUTO-BUILDING the default image on first
58
+ * use so a missing image is never a blocker (the user's ask: "create it when it's
59
+ * used for the first time"). Present image → verify tmux (hard prerequisite).
60
+ * Default image missing → kick off a BACKGROUND build with SSE progress and return
61
+ * `imageBuilding: true` (the case is created regardless; first launch awaits the
62
+ * same dedup'd build). Custom image missing → a real error (we can't build a
63
+ * foreign ref, and `--pull=never` forbids pulling).
64
+ */
65
+ async function ensureCaseImage(broadcast, sessionDocker, name) {
66
+ if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
67
+ const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
68
+ if (!tmuxCheck.ok)
69
+ return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
70
+ return { ok: true, imageBuilding: false };
71
+ }
72
+ if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
73
+ return {
74
+ ok: false,
75
+ error: `base image ${sessionDocker.image} not present; only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull it first.`,
76
+ };
77
+ }
78
+ broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
79
+ void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
80
+ onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
81
+ })
82
+ .then((r) => broadcast(r.ok ? SseEvent.DockerImageBuildComplete : SseEvent.DockerImageBuildFailed, {
83
+ name,
84
+ image: sessionDocker.image,
85
+ error: r.error,
86
+ }))
87
+ .catch((err) => broadcast(SseEvent.DockerImageBuildFailed, { name, image: sessionDocker.image, error: getErrorMessage(err) }));
88
+ return { ok: true, imageBuilding: true };
49
89
  }
50
90
  export function registerCaseRoutes(app, ctx) {
51
91
  // ═══════════════════════════════════════════════════════════════
52
92
  // Case CRUD (list, create, link, detail, fix-plan)
53
93
  // ═══════════════════════════════════════════════════════════════
54
94
  // ========== List Cases ==========
55
- app.get('/api/cases', async () => {
95
+ app.get('/api/cases', async (req) => {
56
96
  const cases = [];
57
- // Get cases from CASES_DIR
97
+ const user = getAuthUser(req);
98
+ const admin = isAdmin(req);
99
+ // Non-admins enumerate their OWN case space; admins see the shared CASES_DIR.
100
+ const listBase = resolveCasesDir(user);
101
+ // Get cases from the user's (or shared) cases dir
58
102
  try {
59
- const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
103
+ const entries = await fs.readdir(listBase, { withFileTypes: true });
60
104
  for (const e of entries) {
61
105
  if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
62
106
  cases.push({
63
107
  name: e.name,
64
- path: join(CASES_DIR, e.name),
65
- hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
108
+ path: join(listBase, e.name),
109
+ hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')),
66
110
  location: 'local',
67
111
  });
68
112
  }
69
113
  }
70
114
  }
71
115
  catch {
72
- // CASES_DIR may not exist yet
116
+ // dir may not exist yet
73
117
  }
74
- // Get linked cases
118
+ // Linked cases (v1 registry has no owner) are admin-only in multi-user mode.
75
119
  const linkedCases = await readLinkedCases();
76
120
  const existingNames = new Set(cases.map((c) => c.name));
77
- for (const [name, path] of Object.entries(linkedCases)) {
78
- if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
79
- cases.push({
80
- name,
81
- path,
82
- hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
83
- linked: true,
84
- location: 'linked-local',
85
- });
121
+ if (admin) {
122
+ for (const [name, path] of Object.entries(linkedCases)) {
123
+ if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
124
+ cases.push({
125
+ name,
126
+ path,
127
+ hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
128
+ linked: true,
129
+ location: 'linked-local',
130
+ });
131
+ }
86
132
  }
87
133
  }
88
- // Get remote cases
134
+ // Get remote cases (owner-scoped; legacy no-owner = admin-only)
89
135
  const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
90
136
  const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
91
137
  for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
92
138
  const host = remoteHostMap.get(remoteCase.hostId);
93
139
  if (!host || !SAFE_CASE_NAME.test(remoteCase.name))
94
140
  continue;
141
+ if (!admin && !canAccessOwned(user, remoteCase.owner))
142
+ continue;
95
143
  existingNames.add(remoteCase.name);
96
144
  const remoteCaseInfo = {
97
145
  name: remoteCase.name,
@@ -120,6 +168,8 @@ export function registerCaseRoutes(app, ctx) {
120
168
  const host = dockerHostMap.get(dockerCase.hostId);
121
169
  if (!host || !SAFE_CASE_NAME.test(dockerCase.name))
122
170
  continue;
171
+ if (!admin && !canAccessOwned(user, dockerCase.owner))
172
+ continue;
123
173
  existingNames.add(dockerCase.name);
124
174
  const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
125
175
  const dockerCaseInfo = {
@@ -158,7 +208,7 @@ export function registerCaseRoutes(app, ctx) {
158
208
  });
159
209
  app.post('/api/cases', async (req) => {
160
210
  const { name, description } = parseBody(CreateCaseSchema, req.body);
161
- const casePath = validatePathWithinBase(name, CASES_DIR);
211
+ const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
162
212
  if (!casePath) {
163
213
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
164
214
  }
@@ -181,8 +231,17 @@ export function registerCaseRoutes(app, ctx) {
181
231
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
182
232
  }
183
233
  });
184
- app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR));
185
- app.post('/api/remote-hosts', async (req) => {
234
+ // Hosts are machine-level infra config (ssh users/identity paths): non-admins get an
235
+ // empty list in multi-user mode, matching the admin-only write side. No-op otherwise.
236
+ app.get('/api/remote-hosts', async (req) => isMultiUserMode() && !isAdmin(req) ? [] : readRemoteHosts(CODEMAN_CONFIG_DIR));
237
+ // Hosts are machine-level resources: only admins may define them in multi-user mode.
238
+ const adminOnly = (req, reply) => isAdmin(req)
239
+ ? null
240
+ : (reply.code(403), createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'));
241
+ app.post('/api/remote-hosts', async (req, reply) => {
242
+ const denied = adminOnly(req, reply);
243
+ if (denied)
244
+ return denied;
186
245
  const host = parseBody(RemoteHostSchema, req.body);
187
246
  const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
188
247
  if (hosts.some((item) => item.id === host.id)) {
@@ -191,7 +250,10 @@ export function registerCaseRoutes(app, ctx) {
191
250
  await writeRemoteHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
192
251
  return { success: true, data: { host } };
193
252
  });
194
- app.put('/api/remote-hosts/:id', async (req) => {
253
+ app.put('/api/remote-hosts/:id', async (req, reply) => {
254
+ const denied = adminOnly(req, reply);
255
+ if (denied)
256
+ return denied;
195
257
  const { id } = req.params;
196
258
  const host = parseBody(RemoteHostSchema, { ...req.body, id });
197
259
  const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
@@ -203,7 +265,10 @@ export function registerCaseRoutes(app, ctx) {
203
265
  await writeRemoteHosts(CODEMAN_CONFIG_DIR, next);
204
266
  return { success: true, data: { host } };
205
267
  });
206
- app.delete('/api/remote-hosts/:id', async (req) => {
268
+ app.delete('/api/remote-hosts/:id', async (req, reply) => {
269
+ const denied = adminOnly(req, reply);
270
+ if (denied)
271
+ return denied;
207
272
  const { id } = req.params;
208
273
  const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
209
274
  if (cases.some((item) => item.hostId === id)) {
@@ -214,7 +279,7 @@ export function registerCaseRoutes(app, ctx) {
214
279
  return { success: true, data: { id } };
215
280
  });
216
281
  app.post('/api/cases/remote-link', async (req) => {
217
- const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' };
282
+ const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote', owner: ownerFor(req) };
218
283
  const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
219
284
  const host = hosts.find((item) => item.id === remoteCase.hostId);
220
285
  if (!host)
@@ -223,7 +288,7 @@ export function registerCaseRoutes(app, ctx) {
223
288
  const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
224
289
  if (remoteCases.some((item) => item.name === remoteCase.name) ||
225
290
  linkedCases[remoteCase.name] ||
226
- existsSync(join(CASES_DIR, remoteCase.name))) {
291
+ existsSync(join(resolveCasesDir(getAuthUser(req)), remoteCase.name))) {
227
292
  return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
228
293
  }
229
294
  // Courtesy validation: tmux is a hard prerequisite for durable remote sessions.
@@ -238,8 +303,13 @@ export function registerCaseRoutes(app, ctx) {
238
303
  return { success: true, data: { case: remoteCase } };
239
304
  });
240
305
  // ========== Docker hosts + docker cases (COD-Docker) ==========
241
- app.get('/api/docker-hosts', async () => readDockerHosts(CODEMAN_CONFIG_DIR));
242
- app.post('/api/docker-hosts', async (req) => {
306
+ // Hosts are machine-level infra config (images/mounts/env): non-admins get an empty
307
+ // list in multi-user mode, matching the admin-only write side. No-op otherwise.
308
+ app.get('/api/docker-hosts', async (req) => isMultiUserMode() && !isAdmin(req) ? [] : readDockerHosts(CODEMAN_CONFIG_DIR));
309
+ app.post('/api/docker-hosts', async (req, reply) => {
310
+ const denied = adminOnly(req, reply);
311
+ if (denied)
312
+ return denied;
243
313
  const host = parseBody(DockerHostSchema, req.body);
244
314
  const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
245
315
  if (hosts.some((item) => item.id === host.id)) {
@@ -248,7 +318,10 @@ export function registerCaseRoutes(app, ctx) {
248
318
  await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
249
319
  return { success: true, data: { host } };
250
320
  });
251
- app.put('/api/docker-hosts/:id', async (req) => {
321
+ app.put('/api/docker-hosts/:id', async (req, reply) => {
322
+ const denied = adminOnly(req, reply);
323
+ if (denied)
324
+ return denied;
252
325
  const { id } = req.params;
253
326
  const host = parseBody(DockerHostSchema, { ...req.body, id });
254
327
  const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
@@ -260,7 +333,10 @@ export function registerCaseRoutes(app, ctx) {
260
333
  await writeDockerHosts(CODEMAN_CONFIG_DIR, next);
261
334
  return { success: true, data: { host } };
262
335
  });
263
- app.delete('/api/docker-hosts/:id', async (req) => {
336
+ app.delete('/api/docker-hosts/:id', async (req, reply) => {
337
+ const denied = adminOnly(req, reply);
338
+ if (denied)
339
+ return denied;
264
340
  const { id } = req.params;
265
341
  const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
266
342
  if (cases.some((item) => item.hostId === id)) {
@@ -271,7 +347,11 @@ export function registerCaseRoutes(app, ctx) {
271
347
  return { success: true, data: { id } };
272
348
  });
273
349
  app.post('/api/cases/docker-link', async (req) => {
274
- const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' };
350
+ const dockerCase = {
351
+ ...parseBody(DockerCaseLinkSchema, req.body),
352
+ type: 'docker',
353
+ owner: ownerFor(req),
354
+ };
275
355
  const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
276
356
  const host = hosts.find((item) => item.id === dockerCase.hostId);
277
357
  if (!host)
@@ -280,9 +360,14 @@ export function registerCaseRoutes(app, ctx) {
280
360
  const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
281
361
  if (dockerCases.some((item) => item.name === dockerCase.name) ||
282
362
  linkedCases[dockerCase.name] ||
283
- existsSync(join(CASES_DIR, dockerCase.name))) {
363
+ existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name))) {
284
364
  return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
285
365
  }
366
+ // Confine the bind-mounted workspace to the caller's own space BEFORE creating it
367
+ // (also removes the arbitrary-dir-creation primitive). No-op for admins/single-user.
368
+ if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) {
369
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace');
370
+ }
286
371
  // The workspace is a REAL host directory (bind-mounted into the container), so
287
372
  // create it now if missing. Scaffolding (.claude/settings.local.json + CLAUDE.md)
288
373
  // is written by quick-start on first launch, matching local-case behaviour.
@@ -294,16 +379,16 @@ export function registerCaseRoutes(app, ctx) {
294
379
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Could not create workspace: ${getErrorMessage(err)}`);
295
380
  }
296
381
  }
297
- // Courtesy validation: docker daemon must be reachable AND the base image must
298
- // contain tmux (a hard prerequisite for durable in-container sessions). Surfaces
299
- // a clear error at link time instead of a dead pane on first launch.
382
+ // Courtesy validation: docker daemon must be reachable. The base image is
383
+ // auto-built on first use (default image) rather than being a link-time
384
+ // blocker, so a missing image kicks off a background build instead of erroring.
300
385
  const availability = await checkDockerAvailable(host.engine);
301
386
  if (!availability.ok) {
302
387
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, availability.error || 'docker daemon is not available');
303
388
  }
304
- const tmuxCheck = await checkDockerTmuxAvailable(toSessionDocker(host, dockerCase));
305
- if (!tmuxCheck.ok) {
306
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
389
+ const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), dockerCase.name);
390
+ if (!imageGate.ok) {
391
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
307
392
  }
308
393
  await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
309
394
  ctx.broadcast(SseEvent.CaseLinked, {
@@ -313,7 +398,12 @@ export function registerCaseRoutes(app, ctx) {
313
398
  });
314
399
  return {
315
400
  success: true,
316
- data: { case: dockerCase, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop },
401
+ data: {
402
+ case: dockerCase,
403
+ capsEnforced: availability.capsEnforced,
404
+ isDesktop: availability.isDesktop,
405
+ imageBuilding: imageGate.imageBuilding,
406
+ },
317
407
  };
318
408
  });
319
409
  // One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
@@ -322,7 +412,7 @@ export function registerCaseRoutes(app, ctx) {
322
412
  app.post('/api/cases/docker-quickcreate', async (req) => {
323
413
  const body = parseBody(DockerQuickCreateSchema, req.body);
324
414
  const { name, description } = body;
325
- const casePath = validatePathWithinBase(name, CASES_DIR);
415
+ const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
326
416
  if (!casePath)
327
417
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
328
418
  // Collision across every case kind.
@@ -371,16 +461,23 @@ export function registerCaseRoutes(app, ctx) {
371
461
  else if (hasOverrides) {
372
462
  await writeDockerHosts(CODEMAN_CONFIG_DIR, hosts.map((h) => (h.id === desiredHost.id ? desiredHost : h)));
373
463
  }
374
- // Probe the daemon + base image BEFORE scaffolding, so a missing docker/image
375
- // surfaces a clear error instead of leaving an orphaned case folder.
464
+ // Probe the daemon BEFORE scaffolding so a missing docker surfaces a clear
465
+ // error instead of leaving an orphaned case folder. The base image is NOT a
466
+ // blocker: a missing default image auto-builds in the background on first use.
376
467
  const availability = await checkDockerAvailable(host.engine);
377
468
  if (!availability.ok) {
378
469
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, availability.error || 'docker daemon is not available');
379
470
  }
380
- const dockerCase = { name, type: 'docker', hostId: host.id, hostWorkspacePath: casePath };
381
- const tmuxCheck = await checkDockerTmuxAvailable(toSessionDocker(host, dockerCase));
382
- if (!tmuxCheck.ok) {
383
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux (build it: node scripts/build-agent-image.mjs)');
471
+ const dockerCase = {
472
+ name,
473
+ type: 'docker',
474
+ hostId: host.id,
475
+ hostWorkspacePath: casePath,
476
+ owner: ownerFor(req),
477
+ };
478
+ const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
479
+ if (!imageGate.ok) {
480
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
384
481
  }
385
482
  // Scaffold the case folder exactly like a normal case.
386
483
  try {
@@ -398,7 +495,12 @@ export function registerCaseRoutes(app, ctx) {
398
495
  ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
399
496
  return {
400
497
  success: true,
401
- data: { case: dockerCase, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop },
498
+ data: {
499
+ case: dockerCase,
500
+ capsEnforced: availability.capsEnforced,
501
+ isDesktop: availability.isDesktop,
502
+ imageBuilding: imageGate.imageBuilding,
503
+ },
402
504
  };
403
505
  });
404
506
  // ========== Docker export / import ==========
@@ -480,9 +582,14 @@ export function registerCaseRoutes(app, ctx) {
480
582
  const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
481
583
  if (dockerCases.some((item) => item.name === newCaseName) ||
482
584
  linkedCases[newCaseName] ||
483
- existsSync(join(CASES_DIR, newCaseName))) {
585
+ existsSync(join(resolveCasesDir(getAuthUser(req)), newCaseName))) {
484
586
  return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
485
587
  }
588
+ // Import extracts a tar into destWorkspacePath (later becomes Session.workingDir):
589
+ // confine it to the caller's own space. No-op for admins/single-user.
590
+ if (!isWorkingDirAllowed(getAuthUser(req), destWorkspacePath)) {
591
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'destWorkspacePath is outside your workspace');
592
+ }
486
593
  const timestamp = Date.now();
487
594
  let result;
488
595
  try {
@@ -515,13 +622,19 @@ export function registerCaseRoutes(app, ctx) {
515
622
  hostId,
516
623
  hostWorkspacePath: destWorkspacePath,
517
624
  containerWorkdir: result.manifest.containerWorkdir,
625
+ owner: ownerFor(req),
518
626
  };
519
627
  await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
520
628
  ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' });
521
629
  return { success: true, data: { case: newCase } };
522
630
  });
523
631
  // Link an existing folder as a case
524
- app.post('/api/cases/link', async (req) => {
632
+ app.post('/api/cases/link', async (req, reply) => {
633
+ // Linking writes an arbitrary absolute path into the shared ownerless registry:
634
+ // admin-only in multi-user mode (mirrors host CRUD + the admin-only GET listing).
635
+ const denied = adminOnly(req, reply);
636
+ if (denied)
637
+ return denied;
525
638
  const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
526
639
  // Expand ~ to home directory
527
640
  const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
@@ -530,7 +643,7 @@ export function registerCaseRoutes(app, ctx) {
530
643
  return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`);
531
644
  }
532
645
  // Check if case name already exists in CASES_DIR
533
- const casePath = join(CASES_DIR, name);
646
+ const casePath = join(resolveCasesDir(getAuthUser(req)), name);
534
647
  if (existsSync(casePath)) {
535
648
  return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
536
649
  }
@@ -558,36 +671,47 @@ export function registerCaseRoutes(app, ctx) {
558
671
  // ========== Delete / Unlink Case ==========
559
672
  app.delete('/api/cases/:name', async (req) => {
560
673
  const { name } = req.params;
561
- if (!validatePathWithinBase(name, CASES_DIR)) {
674
+ const user = getAuthUser(req);
675
+ if (!validatePathWithinBase(name, resolveCasesDir(user))) {
562
676
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
563
677
  }
678
+ // Fold ownership INTO the match (don't early-return): a non-owned same-named remote/
679
+ // docker case is skipped so control falls through to the caller's own local delete.
680
+ // canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical.
564
681
  const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
565
- if (remoteCases.some((item) => item.name === name)) {
566
- await writeRemoteCases(CODEMAN_CONFIG_DIR, remoteCases.filter((item) => item.name !== name));
682
+ if (remoteCases.some((item) => item.name === name && canAccessOwned(user, item.owner))) {
683
+ await writeRemoteCases(CODEMAN_CONFIG_DIR, remoteCases.filter((item) => !(item.name === name && canAccessOwned(user, item.owner))));
567
684
  ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
568
685
  return { success: true, data: { name } };
569
686
  }
570
687
  const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
571
- const dockerCase = dockerCases.find((item) => item.name === name);
688
+ const dockerCase = dockerCases.find((item) => item.name === name && canAccessOwned(user, item.owner));
572
689
  if (dockerCase) {
573
- await writeDockerCases(CODEMAN_CONFIG_DIR, dockerCases.filter((item) => item.name !== name));
690
+ await writeDockerCases(CODEMAN_CONFIG_DIR, dockerCases.filter((item) => item !== dockerCase));
574
691
  // Best-effort `docker rm -f` the per-case container (case-delete is the
575
692
  // explicit teardown that removes it; the bind-mounted workspace survives).
576
693
  const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
577
694
  if (host) {
695
+ const sessionDocker = toSessionDocker(host, dockerCase);
578
696
  try {
579
- exec(buildDockerRemoveCommand(toSessionDocker(host, dockerCase)), { timeout: 15_000 }, () => { });
697
+ exec(buildDockerRemoveCommand(sessionDocker), { timeout: 15_000 }, () => { });
580
698
  }
581
699
  catch {
582
700
  /* best-effort — never blocks the unlink */
583
701
  }
702
+ // Remove the per-container claude-config seed file (account metadata copy).
703
+ await fs
704
+ .rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
705
+ .catch(() => { });
584
706
  }
585
707
  ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
586
708
  return { success: true, data: { name } };
587
709
  }
588
- // Check linked cases first — unlink only, don't delete the actual directory
710
+ // Check linked cases first — unlink only, don't delete the actual directory.
711
+ // Linked cases carry no owner (admin-only WRITE in multi-user mode), so a non-admin
712
+ // must not unlink one either; skip so control falls through to their local delete.
589
713
  const linkedCases = await readLinkedCases();
590
- if (linkedCases[name]) {
714
+ if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) {
591
715
  delete linkedCases[name];
592
716
  try {
593
717
  await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
@@ -599,7 +723,7 @@ export function registerCaseRoutes(app, ctx) {
599
723
  }
600
724
  }
601
725
  // Case in CASES_DIR — delete the entire directory
602
- const casePath = join(CASES_DIR, name);
726
+ const casePath = join(resolveCasesDir(getAuthUser(req)), name);
603
727
  if (!existsSync(casePath)) {
604
728
  return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
605
729
  }
@@ -638,11 +762,15 @@ export function registerCaseRoutes(app, ctx) {
638
762
  });
639
763
  app.get('/api/cases/:name', async (req) => {
640
764
  const { name } = req.params;
641
- if (!validatePathWithinBase(name, CASES_DIR)) {
765
+ if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
642
766
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
643
767
  }
768
+ // Fold ownership INTO the match (don't early-return): a non-owned same-named remote/
769
+ // docker case is skipped so control falls through to the caller's own LOCAL case
770
+ // (remote/docker names are globally unique, local names per-user). No metadata is
771
+ // disclosed for a foreign case. canAccessOwned is allow-all for admins/single-user.
644
772
  const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
645
- const remoteCase = remoteCases.find((item) => item.name === name);
773
+ const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner));
646
774
  if (remoteCase) {
647
775
  const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
648
776
  if (!host)
@@ -660,7 +788,7 @@ export function registerCaseRoutes(app, ctx) {
660
788
  },
661
789
  };
662
790
  }
663
- const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
791
+ const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner));
664
792
  if (dockerCase) {
665
793
  const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
666
794
  if (!host)
@@ -680,11 +808,11 @@ export function registerCaseRoutes(app, ctx) {
680
808
  },
681
809
  };
682
810
  }
683
- const casePath = await resolveCasePath(name);
811
+ const casePath = await resolveCasePath(name, getAuthUser(req));
684
812
  if (!existsSync(casePath)) {
685
813
  return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
686
814
  }
687
- const linked = casePath !== join(CASES_DIR, name);
815
+ const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
688
816
  return {
689
817
  name,
690
818
  path: casePath,
@@ -695,11 +823,11 @@ export function registerCaseRoutes(app, ctx) {
695
823
  // Read @fix_plan.md from a case directory (for wizard to detect existing plans)
696
824
  app.get('/api/cases/:name/fix-plan', async (req) => {
697
825
  const { name } = req.params;
698
- if (!validatePathWithinBase(name, CASES_DIR)) {
826
+ if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
699
827
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
700
828
  }
701
829
  // Get case path (check linked cases first, then CASES_DIR)
702
- const casePath = await resolveCasePath(name);
830
+ const casePath = await resolveCasePath(name, getAuthUser(req));
703
831
  const fixPlanPath = join(casePath, '@fix_plan.md');
704
832
  if (!existsSync(fixPlanPath)) {
705
833
  return { exists: false, content: null, todos: [] };
@@ -784,10 +912,10 @@ export function registerCaseRoutes(app, ctx) {
784
912
  // ========== List Wizard Files ==========
785
913
  app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
786
914
  const { caseName } = req.params;
787
- if (!validatePathWithinBase(caseName, CASES_DIR)) {
915
+ if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) {
788
916
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
789
917
  }
790
- const casePath = await resolveCasePath(caseName);
918
+ const casePath = await resolveCasePath(caseName, getAuthUser(req));
791
919
  const wizardDir = join(casePath, 'ralph-wizard');
792
920
  if (!existsSync(wizardDir)) {
793
921
  return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Ralph wizard directory not found');
@@ -818,14 +946,14 @@ export function registerCaseRoutes(app, ctx) {
818
946
  // Cache disabled to ensure fresh prompts when starting new plan generations
819
947
  app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
820
948
  const { caseName, filePath } = req.params;
821
- if (!validatePathWithinBase(caseName, CASES_DIR)) {
949
+ if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) {
822
950
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
823
951
  }
824
952
  // Prevent browser caching - prompts change between plan generations
825
953
  reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
826
954
  reply.header('Pragma', 'no-cache');
827
955
  reply.header('Expires', '0');
828
- const casePath = await resolveCasePath(caseName);
956
+ const casePath = await resolveCasePath(caseName, getAuthUser(req));
829
957
  const wizardDir = join(casePath, 'ralph-wizard');
830
958
  // Decode the file path (it may be URL encoded)
831
959
  const decodedPath = decodeURIComponent(filePath);