aicodeman 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +20 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +168 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config/multiuser.d.ts +38 -0
  6. package/dist/config/multiuser.d.ts.map +1 -0
  7. package/dist/config/multiuser.js +58 -0
  8. package/dist/config/multiuser.js.map +1 -0
  9. package/dist/cron/cron-service.d.ts +1 -1
  10. package/dist/cron/cron-service.d.ts.map +1 -1
  11. package/dist/cron/cron-service.js +31 -4
  12. package/dist/cron/cron-service.js.map +1 -1
  13. package/dist/mux-interface.d.ts +6 -0
  14. package/dist/mux-interface.d.ts.map +1 -1
  15. package/dist/plan-orchestrator.d.ts +8 -1
  16. package/dist/plan-orchestrator.d.ts.map +1 -1
  17. package/dist/plan-orchestrator.js +20 -1
  18. package/dist/plan-orchestrator.js.map +1 -1
  19. package/dist/push-store.d.ts +17 -5
  20. package/dist/push-store.d.ts.map +1 -1
  21. package/dist/push-store.js +3 -1
  22. package/dist/push-store.js.map +1 -1
  23. package/dist/session-cli-builder.d.ts +1 -1
  24. package/dist/session-cli-builder.d.ts.map +1 -1
  25. package/dist/session-cli-builder.js +7 -2
  26. package/dist/session-cli-builder.js.map +1 -1
  27. package/dist/session.d.ts +9 -0
  28. package/dist/session.d.ts.map +1 -1
  29. package/dist/session.js +19 -2
  30. package/dist/session.js.map +1 -1
  31. package/dist/tmux-manager.d.ts +15 -1
  32. package/dist/tmux-manager.d.ts.map +1 -1
  33. package/dist/tmux-manager.js +21 -6
  34. package/dist/tmux-manager.js.map +1 -1
  35. package/dist/tunnel-manager.d.ts +17 -0
  36. package/dist/tunnel-manager.d.ts.map +1 -1
  37. package/dist/tunnel-manager.js +48 -8
  38. package/dist/tunnel-manager.js.map +1 -1
  39. package/dist/types/api.d.ts +10 -0
  40. package/dist/types/api.d.ts.map +1 -1
  41. package/dist/types/api.js +20 -0
  42. package/dist/types/api.js.map +1 -1
  43. package/dist/types/cron.d.ts +2 -0
  44. package/dist/types/cron.d.ts.map +1 -1
  45. package/dist/types/index.d.ts +1 -0
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/dist/types/index.js +1 -0
  48. package/dist/types/index.js.map +1 -1
  49. package/dist/types/session.d.ts +8 -1
  50. package/dist/types/session.d.ts.map +1 -1
  51. package/dist/types/session.js.map +1 -1
  52. package/dist/types/user.d.ts +59 -0
  53. package/dist/types/user.d.ts.map +1 -0
  54. package/dist/types/user.js +12 -0
  55. package/dist/types/user.js.map +1 -0
  56. package/dist/user-store.d.ts +139 -0
  57. package/dist/user-store.d.ts.map +1 -0
  58. package/dist/user-store.js +433 -0
  59. package/dist/user-store.js.map +1 -0
  60. package/dist/web/admin-audit.d.ts +17 -0
  61. package/dist/web/admin-audit.d.ts.map +1 -0
  62. package/dist/web/admin-audit.js +19 -0
  63. package/dist/web/admin-audit.js.map +1 -0
  64. package/dist/web/middleware/auth.d.ts +8 -0
  65. package/dist/web/middleware/auth.d.ts.map +1 -1
  66. package/dist/web/middleware/auth.js +250 -51
  67. package/dist/web/middleware/auth.js.map +1 -1
  68. package/dist/web/ports/auth-port.d.ts +13 -0
  69. package/dist/web/ports/auth-port.d.ts.map +1 -1
  70. package/dist/web/ports/config-port.d.ts +4 -1
  71. package/dist/web/ports/config-port.d.ts.map +1 -1
  72. package/dist/web/ports/infra-port.d.ts +4 -1
  73. package/dist/web/ports/infra-port.d.ts.map +1 -1
  74. package/dist/web/public/admin-ui.js +260 -0
  75. package/dist/web/public/admin-ui.js.br +0 -0
  76. package/dist/web/public/admin-ui.js.gz +0 -0
  77. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  78. package/dist/web/public/app.6c4b9893.js.gz +0 -0
  79. package/dist/web/public/{constants.48da7f03.js → constants.5ce97313.js} +3 -0
  80. package/dist/web/public/constants.5ce97313.js.br +0 -0
  81. package/dist/web/public/{constants.48da7f03.js.gz → constants.5ce97313.js.gz} +0 -0
  82. package/dist/web/public/cron-ui.js.gz +0 -0
  83. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  84. package/dist/web/public/index.html +5 -3
  85. package/dist/web/public/index.html.br +0 -0
  86. package/dist/web/public/index.html.gz +0 -0
  87. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  88. package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
  89. package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
  90. package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
  91. package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
  92. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  93. package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
  94. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  95. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  96. package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
  97. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  98. package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
  99. package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
  100. package/dist/web/public/styles.9e6cdb9d.css +1 -0
  101. package/dist/web/public/styles.9e6cdb9d.css.br +0 -0
  102. package/dist/web/public/styles.9e6cdb9d.css.gz +0 -0
  103. package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
  104. package/dist/web/public/sw.js.gz +0 -0
  105. package/dist/web/public/terminal-ui.efda6083.js.gz +0 -0
  106. package/dist/web/public/ultracode-panel.js.gz +0 -0
  107. package/dist/web/public/ultracode-windows.js.gz +0 -0
  108. package/dist/web/public/upload.html.gz +0 -0
  109. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  110. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  111. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  112. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  113. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  114. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  115. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  116. package/dist/web/public/vendor/xterm.css.gz +0 -0
  117. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  118. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  119. package/dist/web/route-helpers.d.ts +78 -1
  120. package/dist/web/route-helpers.d.ts.map +1 -1
  121. package/dist/web/route-helpers.js +172 -3
  122. package/dist/web/route-helpers.js.map +1 -1
  123. package/dist/web/routes/admin-routes.d.ts +20 -0
  124. package/dist/web/routes/admin-routes.d.ts.map +1 -0
  125. package/dist/web/routes/admin-routes.js +193 -0
  126. package/dist/web/routes/admin-routes.js.map +1 -0
  127. package/dist/web/routes/case-routes.d.ts.map +1 -1
  128. package/dist/web/routes/case-routes.js +137 -59
  129. package/dist/web/routes/case-routes.js.map +1 -1
  130. package/dist/web/routes/clipboard-routes.d.ts +2 -2
  131. package/dist/web/routes/clipboard-routes.d.ts.map +1 -1
  132. package/dist/web/routes/clipboard-routes.js +10 -0
  133. package/dist/web/routes/clipboard-routes.js.map +1 -1
  134. package/dist/web/routes/cron-routes.d.ts.map +1 -1
  135. package/dist/web/routes/cron-routes.js +57 -9
  136. package/dist/web/routes/cron-routes.js.map +1 -1
  137. package/dist/web/routes/file-routes.d.ts.map +1 -1
  138. package/dist/web/routes/file-routes.js +26 -20
  139. package/dist/web/routes/file-routes.js.map +1 -1
  140. package/dist/web/routes/index.d.ts +2 -0
  141. package/dist/web/routes/index.d.ts.map +1 -1
  142. package/dist/web/routes/index.js +2 -0
  143. package/dist/web/routes/index.js.map +1 -1
  144. package/dist/web/routes/me-routes.d.ts +16 -0
  145. package/dist/web/routes/me-routes.d.ts.map +1 -0
  146. package/dist/web/routes/me-routes.js +73 -0
  147. package/dist/web/routes/me-routes.js.map +1 -0
  148. package/dist/web/routes/mux-routes.d.ts.map +1 -1
  149. package/dist/web/routes/mux-routes.js +23 -5
  150. package/dist/web/routes/mux-routes.js.map +1 -1
  151. package/dist/web/routes/orchestrator-routes.d.ts.map +1 -1
  152. package/dist/web/routes/orchestrator-routes.js +43 -11
  153. package/dist/web/routes/orchestrator-routes.js.map +1 -1
  154. package/dist/web/routes/plan-routes.d.ts.map +1 -1
  155. package/dist/web/routes/plan-routes.js +28 -9
  156. package/dist/web/routes/plan-routes.js.map +1 -1
  157. package/dist/web/routes/push-routes.d.ts.map +1 -1
  158. package/dist/web/routes/push-routes.js +4 -0
  159. package/dist/web/routes/push-routes.js.map +1 -1
  160. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  161. package/dist/web/routes/ralph-routes.js +21 -17
  162. package/dist/web/routes/ralph-routes.js.map +1 -1
  163. package/dist/web/routes/respawn-routes.d.ts.map +1 -1
  164. package/dist/web/routes/respawn-routes.js +23 -14
  165. package/dist/web/routes/respawn-routes.js.map +1 -1
  166. package/dist/web/routes/scheduled-routes.d.ts.map +1 -1
  167. package/dist/web/routes/scheduled-routes.js +22 -6
  168. package/dist/web/routes/scheduled-routes.js.map +1 -1
  169. package/dist/web/routes/search-routes.d.ts.map +1 -1
  170. package/dist/web/routes/search-routes.js +9 -3
  171. package/dist/web/routes/search-routes.js.map +1 -1
  172. package/dist/web/routes/session-routes.d.ts.map +1 -1
  173. package/dist/web/routes/session-routes.js +188 -61
  174. package/dist/web/routes/session-routes.js.map +1 -1
  175. package/dist/web/routes/system-routes.d.ts.map +1 -1
  176. package/dist/web/routes/system-routes.js +108 -23
  177. package/dist/web/routes/system-routes.js.map +1 -1
  178. package/dist/web/routes/ws-routes.d.ts.map +1 -1
  179. package/dist/web/routes/ws-routes.js +11 -0
  180. package/dist/web/routes/ws-routes.js.map +1 -1
  181. package/dist/web/server.d.ts +12 -0
  182. package/dist/web/server.d.ts.map +1 -1
  183. package/dist/web/server.js +187 -11
  184. package/dist/web/server.js.map +1 -1
  185. package/dist/web/sse-events.d.ts +6 -0
  186. package/dist/web/sse-events.d.ts.map +1 -1
  187. package/dist/web/sse-events.js +7 -0
  188. package/dist/web/sse-events.js.map +1 -1
  189. package/dist/web/sse-stream-manager.d.ts +32 -2
  190. package/dist/web/sse-stream-manager.d.ts.map +1 -1
  191. package/dist/web/sse-stream-manager.js +51 -4
  192. package/dist/web/sse-stream-manager.js.map +1 -1
  193. package/package.json +1 -1
  194. package/dist/web/public/constants.48da7f03.js.br +0 -0
  195. package/dist/web/public/styles.ff43f80f.css +0 -1
  196. package/dist/web/public/styles.ff43f80f.css.br +0 -0
  197. package/dist/web/public/styles.ff43f80f.css.gz +0 -0
@@ -14,7 +14,8 @@ 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
21
  import { checkDockerAvailable, checkDockerImagePresent, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, dockerContainerName, dockerDisplayPath, readDockerCases, readDockerHosts, toSessionDocker, writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js';
@@ -40,12 +41,17 @@ 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);
49
55
  }
50
56
  /**
51
57
  * Gate a docker case on its base image, AUTO-BUILDING the default image on first
@@ -86,46 +92,54 @@ export function registerCaseRoutes(app, ctx) {
86
92
  // Case CRUD (list, create, link, detail, fix-plan)
87
93
  // ═══════════════════════════════════════════════════════════════
88
94
  // ========== List Cases ==========
89
- app.get('/api/cases', async () => {
95
+ app.get('/api/cases', async (req) => {
90
96
  const cases = [];
91
- // 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
92
102
  try {
93
- const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
103
+ const entries = await fs.readdir(listBase, { withFileTypes: true });
94
104
  for (const e of entries) {
95
105
  if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
96
106
  cases.push({
97
107
  name: e.name,
98
- path: join(CASES_DIR, e.name),
99
- hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
108
+ path: join(listBase, e.name),
109
+ hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')),
100
110
  location: 'local',
101
111
  });
102
112
  }
103
113
  }
104
114
  }
105
115
  catch {
106
- // CASES_DIR may not exist yet
116
+ // dir may not exist yet
107
117
  }
108
- // Get linked cases
118
+ // Linked cases (v1 registry has no owner) are admin-only in multi-user mode.
109
119
  const linkedCases = await readLinkedCases();
110
120
  const existingNames = new Set(cases.map((c) => c.name));
111
- for (const [name, path] of Object.entries(linkedCases)) {
112
- if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
113
- cases.push({
114
- name,
115
- path,
116
- hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
117
- linked: true,
118
- location: 'linked-local',
119
- });
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
+ }
120
132
  }
121
133
  }
122
- // Get remote cases
134
+ // Get remote cases (owner-scoped; legacy no-owner = admin-only)
123
135
  const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
124
136
  const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
125
137
  for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
126
138
  const host = remoteHostMap.get(remoteCase.hostId);
127
139
  if (!host || !SAFE_CASE_NAME.test(remoteCase.name))
128
140
  continue;
141
+ if (!admin && !canAccessOwned(user, remoteCase.owner))
142
+ continue;
129
143
  existingNames.add(remoteCase.name);
130
144
  const remoteCaseInfo = {
131
145
  name: remoteCase.name,
@@ -154,6 +168,8 @@ export function registerCaseRoutes(app, ctx) {
154
168
  const host = dockerHostMap.get(dockerCase.hostId);
155
169
  if (!host || !SAFE_CASE_NAME.test(dockerCase.name))
156
170
  continue;
171
+ if (!admin && !canAccessOwned(user, dockerCase.owner))
172
+ continue;
157
173
  existingNames.add(dockerCase.name);
158
174
  const container = dockerCase.container ?? dockerContainerName(dockerCase.name);
159
175
  const dockerCaseInfo = {
@@ -192,7 +208,7 @@ export function registerCaseRoutes(app, ctx) {
192
208
  });
193
209
  app.post('/api/cases', async (req) => {
194
210
  const { name, description } = parseBody(CreateCaseSchema, req.body);
195
- const casePath = validatePathWithinBase(name, CASES_DIR);
211
+ const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
196
212
  if (!casePath) {
197
213
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
198
214
  }
@@ -215,8 +231,17 @@ export function registerCaseRoutes(app, ctx) {
215
231
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
216
232
  }
217
233
  });
218
- app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR));
219
- 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;
220
245
  const host = parseBody(RemoteHostSchema, req.body);
221
246
  const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
222
247
  if (hosts.some((item) => item.id === host.id)) {
@@ -225,7 +250,10 @@ export function registerCaseRoutes(app, ctx) {
225
250
  await writeRemoteHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
226
251
  return { success: true, data: { host } };
227
252
  });
228
- 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;
229
257
  const { id } = req.params;
230
258
  const host = parseBody(RemoteHostSchema, { ...req.body, id });
231
259
  const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
@@ -237,7 +265,10 @@ export function registerCaseRoutes(app, ctx) {
237
265
  await writeRemoteHosts(CODEMAN_CONFIG_DIR, next);
238
266
  return { success: true, data: { host } };
239
267
  });
240
- 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;
241
272
  const { id } = req.params;
242
273
  const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
243
274
  if (cases.some((item) => item.hostId === id)) {
@@ -248,7 +279,7 @@ export function registerCaseRoutes(app, ctx) {
248
279
  return { success: true, data: { id } };
249
280
  });
250
281
  app.post('/api/cases/remote-link', async (req) => {
251
- const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' };
282
+ const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote', owner: ownerFor(req) };
252
283
  const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
253
284
  const host = hosts.find((item) => item.id === remoteCase.hostId);
254
285
  if (!host)
@@ -257,7 +288,7 @@ export function registerCaseRoutes(app, ctx) {
257
288
  const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
258
289
  if (remoteCases.some((item) => item.name === remoteCase.name) ||
259
290
  linkedCases[remoteCase.name] ||
260
- existsSync(join(CASES_DIR, remoteCase.name))) {
291
+ existsSync(join(resolveCasesDir(getAuthUser(req)), remoteCase.name))) {
261
292
  return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
262
293
  }
263
294
  // Courtesy validation: tmux is a hard prerequisite for durable remote sessions.
@@ -272,8 +303,13 @@ export function registerCaseRoutes(app, ctx) {
272
303
  return { success: true, data: { case: remoteCase } };
273
304
  });
274
305
  // ========== Docker hosts + docker cases (COD-Docker) ==========
275
- app.get('/api/docker-hosts', async () => readDockerHosts(CODEMAN_CONFIG_DIR));
276
- 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;
277
313
  const host = parseBody(DockerHostSchema, req.body);
278
314
  const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
279
315
  if (hosts.some((item) => item.id === host.id)) {
@@ -282,7 +318,10 @@ export function registerCaseRoutes(app, ctx) {
282
318
  await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
283
319
  return { success: true, data: { host } };
284
320
  });
285
- 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;
286
325
  const { id } = req.params;
287
326
  const host = parseBody(DockerHostSchema, { ...req.body, id });
288
327
  const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
@@ -294,7 +333,10 @@ export function registerCaseRoutes(app, ctx) {
294
333
  await writeDockerHosts(CODEMAN_CONFIG_DIR, next);
295
334
  return { success: true, data: { host } };
296
335
  });
297
- 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;
298
340
  const { id } = req.params;
299
341
  const cases = await readDockerCases(CODEMAN_CONFIG_DIR);
300
342
  if (cases.some((item) => item.hostId === id)) {
@@ -305,7 +347,11 @@ export function registerCaseRoutes(app, ctx) {
305
347
  return { success: true, data: { id } };
306
348
  });
307
349
  app.post('/api/cases/docker-link', async (req) => {
308
- const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' };
350
+ const dockerCase = {
351
+ ...parseBody(DockerCaseLinkSchema, req.body),
352
+ type: 'docker',
353
+ owner: ownerFor(req),
354
+ };
309
355
  const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
310
356
  const host = hosts.find((item) => item.id === dockerCase.hostId);
311
357
  if (!host)
@@ -314,9 +360,14 @@ export function registerCaseRoutes(app, ctx) {
314
360
  const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
315
361
  if (dockerCases.some((item) => item.name === dockerCase.name) ||
316
362
  linkedCases[dockerCase.name] ||
317
- existsSync(join(CASES_DIR, dockerCase.name))) {
363
+ existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name))) {
318
364
  return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
319
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
+ }
320
371
  // The workspace is a REAL host directory (bind-mounted into the container), so
321
372
  // create it now if missing. Scaffolding (.claude/settings.local.json + CLAUDE.md)
322
373
  // is written by quick-start on first launch, matching local-case behaviour.
@@ -361,7 +412,7 @@ export function registerCaseRoutes(app, ctx) {
361
412
  app.post('/api/cases/docker-quickcreate', async (req) => {
362
413
  const body = parseBody(DockerQuickCreateSchema, req.body);
363
414
  const { name, description } = body;
364
- const casePath = validatePathWithinBase(name, CASES_DIR);
415
+ const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
365
416
  if (!casePath)
366
417
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
367
418
  // Collision across every case kind.
@@ -417,7 +468,13 @@ export function registerCaseRoutes(app, ctx) {
417
468
  if (!availability.ok) {
418
469
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, availability.error || 'docker daemon is not available');
419
470
  }
420
- const dockerCase = { name, type: 'docker', hostId: host.id, hostWorkspacePath: casePath };
471
+ const dockerCase = {
472
+ name,
473
+ type: 'docker',
474
+ hostId: host.id,
475
+ hostWorkspacePath: casePath,
476
+ owner: ownerFor(req),
477
+ };
421
478
  const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
422
479
  if (!imageGate.ok) {
423
480
  return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
@@ -525,9 +582,14 @@ export function registerCaseRoutes(app, ctx) {
525
582
  const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
526
583
  if (dockerCases.some((item) => item.name === newCaseName) ||
527
584
  linkedCases[newCaseName] ||
528
- existsSync(join(CASES_DIR, newCaseName))) {
585
+ existsSync(join(resolveCasesDir(getAuthUser(req)), newCaseName))) {
529
586
  return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
530
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
+ }
531
593
  const timestamp = Date.now();
532
594
  let result;
533
595
  try {
@@ -560,13 +622,19 @@ export function registerCaseRoutes(app, ctx) {
560
622
  hostId,
561
623
  hostWorkspacePath: destWorkspacePath,
562
624
  containerWorkdir: result.manifest.containerWorkdir,
625
+ owner: ownerFor(req),
563
626
  };
564
627
  await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]);
565
628
  ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' });
566
629
  return { success: true, data: { case: newCase } };
567
630
  });
568
631
  // Link an existing folder as a case
569
- 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;
570
638
  const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
571
639
  // Expand ~ to home directory
572
640
  const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
@@ -575,7 +643,7 @@ export function registerCaseRoutes(app, ctx) {
575
643
  return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`);
576
644
  }
577
645
  // Check if case name already exists in CASES_DIR
578
- const casePath = join(CASES_DIR, name);
646
+ const casePath = join(resolveCasesDir(getAuthUser(req)), name);
579
647
  if (existsSync(casePath)) {
580
648
  return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
581
649
  }
@@ -603,19 +671,23 @@ export function registerCaseRoutes(app, ctx) {
603
671
  // ========== Delete / Unlink Case ==========
604
672
  app.delete('/api/cases/:name', async (req) => {
605
673
  const { name } = req.params;
606
- if (!validatePathWithinBase(name, CASES_DIR)) {
674
+ const user = getAuthUser(req);
675
+ if (!validatePathWithinBase(name, resolveCasesDir(user))) {
607
676
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
608
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.
609
681
  const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
610
- if (remoteCases.some((item) => item.name === name)) {
611
- 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))));
612
684
  ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
613
685
  return { success: true, data: { name } };
614
686
  }
615
687
  const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
616
- const dockerCase = dockerCases.find((item) => item.name === name);
688
+ const dockerCase = dockerCases.find((item) => item.name === name && canAccessOwned(user, item.owner));
617
689
  if (dockerCase) {
618
- await writeDockerCases(CODEMAN_CONFIG_DIR, dockerCases.filter((item) => item.name !== name));
690
+ await writeDockerCases(CODEMAN_CONFIG_DIR, dockerCases.filter((item) => item !== dockerCase));
619
691
  // Best-effort `docker rm -f` the per-case container (case-delete is the
620
692
  // explicit teardown that removes it; the bind-mounted workspace survives).
621
693
  const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
@@ -635,9 +707,11 @@ export function registerCaseRoutes(app, ctx) {
635
707
  ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
636
708
  return { success: true, data: { name } };
637
709
  }
638
- // 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.
639
713
  const linkedCases = await readLinkedCases();
640
- if (linkedCases[name]) {
714
+ if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) {
641
715
  delete linkedCases[name];
642
716
  try {
643
717
  await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
@@ -649,7 +723,7 @@ export function registerCaseRoutes(app, ctx) {
649
723
  }
650
724
  }
651
725
  // Case in CASES_DIR — delete the entire directory
652
- const casePath = join(CASES_DIR, name);
726
+ const casePath = join(resolveCasesDir(getAuthUser(req)), name);
653
727
  if (!existsSync(casePath)) {
654
728
  return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
655
729
  }
@@ -688,11 +762,15 @@ export function registerCaseRoutes(app, ctx) {
688
762
  });
689
763
  app.get('/api/cases/:name', async (req) => {
690
764
  const { name } = req.params;
691
- if (!validatePathWithinBase(name, CASES_DIR)) {
765
+ if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
692
766
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
693
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.
694
772
  const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
695
- const remoteCase = remoteCases.find((item) => item.name === name);
773
+ const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner));
696
774
  if (remoteCase) {
697
775
  const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
698
776
  if (!host)
@@ -710,7 +788,7 @@ export function registerCaseRoutes(app, ctx) {
710
788
  },
711
789
  };
712
790
  }
713
- 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));
714
792
  if (dockerCase) {
715
793
  const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
716
794
  if (!host)
@@ -730,11 +808,11 @@ export function registerCaseRoutes(app, ctx) {
730
808
  },
731
809
  };
732
810
  }
733
- const casePath = await resolveCasePath(name);
811
+ const casePath = await resolveCasePath(name, getAuthUser(req));
734
812
  if (!existsSync(casePath)) {
735
813
  return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
736
814
  }
737
- const linked = casePath !== join(CASES_DIR, name);
815
+ const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
738
816
  return {
739
817
  name,
740
818
  path: casePath,
@@ -745,11 +823,11 @@ export function registerCaseRoutes(app, ctx) {
745
823
  // Read @fix_plan.md from a case directory (for wizard to detect existing plans)
746
824
  app.get('/api/cases/:name/fix-plan', async (req) => {
747
825
  const { name } = req.params;
748
- if (!validatePathWithinBase(name, CASES_DIR)) {
826
+ if (!validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)))) {
749
827
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
750
828
  }
751
829
  // Get case path (check linked cases first, then CASES_DIR)
752
- const casePath = await resolveCasePath(name);
830
+ const casePath = await resolveCasePath(name, getAuthUser(req));
753
831
  const fixPlanPath = join(casePath, '@fix_plan.md');
754
832
  if (!existsSync(fixPlanPath)) {
755
833
  return { exists: false, content: null, todos: [] };
@@ -834,10 +912,10 @@ export function registerCaseRoutes(app, ctx) {
834
912
  // ========== List Wizard Files ==========
835
913
  app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
836
914
  const { caseName } = req.params;
837
- if (!validatePathWithinBase(caseName, CASES_DIR)) {
915
+ if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) {
838
916
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
839
917
  }
840
- const casePath = await resolveCasePath(caseName);
918
+ const casePath = await resolveCasePath(caseName, getAuthUser(req));
841
919
  const wizardDir = join(casePath, 'ralph-wizard');
842
920
  if (!existsSync(wizardDir)) {
843
921
  return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Ralph wizard directory not found');
@@ -868,14 +946,14 @@ export function registerCaseRoutes(app, ctx) {
868
946
  // Cache disabled to ensure fresh prompts when starting new plan generations
869
947
  app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
870
948
  const { caseName, filePath } = req.params;
871
- if (!validatePathWithinBase(caseName, CASES_DIR)) {
949
+ if (!validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)))) {
872
950
  return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
873
951
  }
874
952
  // Prevent browser caching - prompts change between plan generations
875
953
  reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
876
954
  reply.header('Pragma', 'no-cache');
877
955
  reply.header('Expires', '0');
878
- const casePath = await resolveCasePath(caseName);
956
+ const casePath = await resolveCasePath(caseName, getAuthUser(req));
879
957
  const wizardDir = join(casePath, 'ralph-wizard');
880
958
  // Decode the file path (it may be URL encoded)
881
959
  const decodedPath = decodeURIComponent(filePath);