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.
- package/README.md +20 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +168 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/multiuser.d.ts +38 -0
- package/dist/config/multiuser.d.ts.map +1 -0
- package/dist/config/multiuser.js +58 -0
- package/dist/config/multiuser.js.map +1 -0
- package/dist/cron/cron-service.d.ts +1 -1
- package/dist/cron/cron-service.d.ts.map +1 -1
- package/dist/cron/cron-service.js +31 -4
- package/dist/cron/cron-service.js.map +1 -1
- package/dist/mux-interface.d.ts +6 -0
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/plan-orchestrator.d.ts +8 -1
- package/dist/plan-orchestrator.d.ts.map +1 -1
- package/dist/plan-orchestrator.js +20 -1
- package/dist/plan-orchestrator.js.map +1 -1
- package/dist/push-store.d.ts +17 -5
- package/dist/push-store.d.ts.map +1 -1
- package/dist/push-store.js +3 -1
- package/dist/push-store.js.map +1 -1
- package/dist/session-cli-builder.d.ts +1 -1
- package/dist/session-cli-builder.d.ts.map +1 -1
- package/dist/session-cli-builder.js +7 -2
- package/dist/session-cli-builder.js.map +1 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +19 -2
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts +15 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +21 -6
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tunnel-manager.d.ts +17 -0
- package/dist/tunnel-manager.d.ts.map +1 -1
- package/dist/tunnel-manager.js +48 -8
- package/dist/tunnel-manager.js.map +1 -1
- package/dist/types/api.d.ts +10 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/api.js +20 -0
- package/dist/types/api.js.map +1 -1
- package/dist/types/cron.d.ts +2 -0
- package/dist/types/cron.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/session.d.ts +8 -1
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/types/user.d.ts +59 -0
- package/dist/types/user.d.ts.map +1 -0
- package/dist/types/user.js +12 -0
- package/dist/types/user.js.map +1 -0
- package/dist/user-store.d.ts +139 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +433 -0
- package/dist/user-store.js.map +1 -0
- package/dist/web/admin-audit.d.ts +17 -0
- package/dist/web/admin-audit.d.ts.map +1 -0
- package/dist/web/admin-audit.js +19 -0
- package/dist/web/admin-audit.js.map +1 -0
- package/dist/web/middleware/auth.d.ts +8 -0
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +250 -51
- package/dist/web/middleware/auth.js.map +1 -1
- package/dist/web/ports/auth-port.d.ts +13 -0
- package/dist/web/ports/auth-port.d.ts.map +1 -1
- package/dist/web/ports/config-port.d.ts +4 -1
- package/dist/web/ports/config-port.d.ts.map +1 -1
- package/dist/web/ports/infra-port.d.ts +4 -1
- package/dist/web/ports/infra-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js +260 -0
- package/dist/web/public/admin-ui.js.br +0 -0
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.6c4b9893.js.gz +0 -0
- package/dist/web/public/{constants.48da7f03.js → constants.5ce97313.js} +3 -0
- package/dist/web/public/constants.5ce97313.js.br +0 -0
- package/dist/web/public/{constants.48da7f03.js.gz → constants.5ce97313.js.gz} +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +5 -3
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
- package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
- package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
- package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
- package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
- package/dist/web/public/styles.9e6cdb9d.css +1 -0
- package/dist/web/public/styles.9e6cdb9d.css.br +0 -0
- package/dist/web/public/styles.9e6cdb9d.css.gz +0 -0
- package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.efda6083.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
- package/dist/web/route-helpers.d.ts +78 -1
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +172 -3
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/admin-routes.d.ts +20 -0
- package/dist/web/routes/admin-routes.d.ts.map +1 -0
- package/dist/web/routes/admin-routes.js +193 -0
- package/dist/web/routes/admin-routes.js.map +1 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +137 -59
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/clipboard-routes.d.ts +2 -2
- package/dist/web/routes/clipboard-routes.d.ts.map +1 -1
- package/dist/web/routes/clipboard-routes.js +10 -0
- package/dist/web/routes/clipboard-routes.js.map +1 -1
- package/dist/web/routes/cron-routes.d.ts.map +1 -1
- package/dist/web/routes/cron-routes.js +57 -9
- package/dist/web/routes/cron-routes.js.map +1 -1
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +26 -20
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +2 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +2 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/me-routes.d.ts +16 -0
- package/dist/web/routes/me-routes.d.ts.map +1 -0
- package/dist/web/routes/me-routes.js +73 -0
- package/dist/web/routes/me-routes.js.map +1 -0
- package/dist/web/routes/mux-routes.d.ts.map +1 -1
- package/dist/web/routes/mux-routes.js +23 -5
- package/dist/web/routes/mux-routes.js.map +1 -1
- package/dist/web/routes/orchestrator-routes.d.ts.map +1 -1
- package/dist/web/routes/orchestrator-routes.js +43 -11
- package/dist/web/routes/orchestrator-routes.js.map +1 -1
- package/dist/web/routes/plan-routes.d.ts.map +1 -1
- package/dist/web/routes/plan-routes.js +28 -9
- package/dist/web/routes/plan-routes.js.map +1 -1
- package/dist/web/routes/push-routes.d.ts.map +1 -1
- package/dist/web/routes/push-routes.js +4 -0
- package/dist/web/routes/push-routes.js.map +1 -1
- package/dist/web/routes/ralph-routes.d.ts.map +1 -1
- package/dist/web/routes/ralph-routes.js +21 -17
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/respawn-routes.d.ts.map +1 -1
- package/dist/web/routes/respawn-routes.js +23 -14
- package/dist/web/routes/respawn-routes.js.map +1 -1
- package/dist/web/routes/scheduled-routes.d.ts.map +1 -1
- package/dist/web/routes/scheduled-routes.js +22 -6
- package/dist/web/routes/scheduled-routes.js.map +1 -1
- package/dist/web/routes/search-routes.d.ts.map +1 -1
- package/dist/web/routes/search-routes.js +9 -3
- package/dist/web/routes/search-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +188 -61
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +108 -23
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/routes/ws-routes.d.ts.map +1 -1
- package/dist/web/routes/ws-routes.js +11 -0
- package/dist/web/routes/ws-routes.js.map +1 -1
- package/dist/web/server.d.ts +12 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +187 -11
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +6 -0
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +7 -0
- package/dist/web/sse-events.js.map +1 -1
- package/dist/web/sse-stream-manager.d.ts +32 -2
- package/dist/web/sse-stream-manager.d.ts.map +1 -1
- package/dist/web/sse-stream-manager.js +51 -4
- package/dist/web/sse-stream-manager.js.map +1 -1
- package/package.json +1 -1
- package/dist/web/public/constants.48da7f03.js.br +0 -0
- package/dist/web/public/styles.ff43f80f.css +0 -1
- package/dist/web/public/styles.ff43f80f.css.br +0 -0
- 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 {
|
|
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
|
-
/**
|
|
44
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
99
|
-
hasClaudeMd: existsSync(join(
|
|
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
|
-
//
|
|
116
|
+
// dir may not exist yet
|
|
107
117
|
}
|
|
108
|
-
//
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
|
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
|
-
|
|
219
|
-
|
|
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(
|
|
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
|
-
|
|
276
|
-
|
|
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 = {
|
|
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(
|
|
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,
|
|
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 = {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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);
|