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.
- package/README.md +36 -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/docker-hosts.d.ts +85 -5
- package/dist/docker-hosts.d.ts.map +1 -1
- package/dist/docker-hosts.js +260 -22
- package/dist/docker-hosts.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 +24 -2
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +60 -12
- 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.07e61fcf.js → app.6c4b9893.js} +1 -1
- package/dist/web/public/app.6c4b9893.js.br +0 -0
- package/dist/web/public/app.6c4b9893.js.gz +0 -0
- package/dist/web/public/{constants.503109fa.js → constants.5ce97313.js} +7 -0
- package/dist/web/public/constants.5ce97313.js.br +0 -0
- package/dist/web/public/{constants.503109fa.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 +24 -14
- 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.ca03853f.js → panels-ui.3c9b0465.js} +53 -53
- package/dist/web/public/panels-ui.3c9b0465.js.br +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 +58 -0
- package/dist/web/public/session-ui.4851c61a.js.br +0 -0
- package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
- package/dist/web/public/settings-ui.29214cf1.js +67 -0
- package/dist/web/public/settings-ui.29214cf1.js.br +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 +202 -74
- 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 +209 -65
- 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/schemas.d.ts +1 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +3 -0
- package/dist/web/schemas.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 +18 -0
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +19 -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/app.07e61fcf.js.br +0 -0
- package/dist/web/public/app.07e61fcf.js.gz +0 -0
- package/dist/web/public/constants.503109fa.js.br +0 -0
- package/dist/web/public/panels-ui.ca03853f.js.br +0 -0
- package/dist/web/public/panels-ui.ca03853f.js.gz +0 -0
- package/dist/web/public/session-ui.9da1b71f.js +0 -58
- package/dist/web/public/session-ui.9da1b71f.js.br +0 -0
- package/dist/web/public/session-ui.9da1b71f.js.gz +0 -0
- package/dist/web/public/settings-ui.4acbe789.js +0 -67
- package/dist/web/public/settings-ui.4acbe789.js.br +0 -0
- package/dist/web/public/settings-ui.4acbe789.js.gz +0 -0
- package/dist/web/public/styles.546ddae4.css +0 -1
- package/dist/web/public/styles.546ddae4.css.br +0 -0
- 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 {
|
|
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
|
-
/**
|
|
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);
|
|
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
|
-
|
|
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(
|
|
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(
|
|
65
|
-
hasClaudeMd: existsSync(join(
|
|
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
|
-
//
|
|
116
|
+
// dir may not exist yet
|
|
73
117
|
}
|
|
74
|
-
//
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
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
|
-
|
|
185
|
-
|
|
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(
|
|
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
|
-
|
|
242
|
-
|
|
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 = {
|
|
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(
|
|
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
|
|
298
|
-
//
|
|
299
|
-
//
|
|
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
|
|
305
|
-
if (!
|
|
306
|
-
return createErrorResponse(ApiErrorCode.OPERATION_FAILED,
|
|
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: {
|
|
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,
|
|
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
|
|
375
|
-
//
|
|
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 = {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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: {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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);
|