@glwhappen/web-code 1.32.0 → 1.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-u6XmIqLb.js → index-CfT-2Nkf.js} +2 -2
- package/dist/index.html +1 -1
- package/dist-server/server/cursor-cli.js +33 -15
- package/dist-server/server/cursor-cli.js.map +1 -1
- package/dist-server/server/gemini-cli.js +48 -29
- package/dist-server/server/gemini-cli.js.map +1 -1
- package/dist-server/server/index.js +20 -17
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/database/repositories/push-subscriptions.js +11 -5
- package/dist-server/server/modules/database/repositories/push-subscriptions.js.map +1 -1
- package/dist-server/server/modules/projects/projects.routes.js +14 -0
- package/dist-server/server/modules/projects/projects.routes.js.map +1 -1
- package/dist-server/server/modules/projects/services/project-authorization.service.js +54 -0
- package/dist-server/server/modules/projects/services/project-authorization.service.js.map +1 -0
- package/dist-server/server/modules/projects/services/project-clone.service.js +13 -4
- package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
- package/dist-server/server/modules/projects/services/project-management.service.js +4 -2
- package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
- package/dist-server/server/modules/projects/tests/project-authorization.service.test.js +51 -0
- package/dist-server/server/modules/projects/tests/project-authorization.service.test.js.map +1 -0
- package/dist-server/server/modules/projects/tests/project-clone.service.test.js +10 -1
- package/dist-server/server/modules/projects/tests/project-clone.service.test.js.map +1 -1
- package/dist-server/server/modules/projects/tests/project-management.service.test.js +31 -10
- package/dist-server/server/modules/projects/tests/project-management.service.test.js.map +1 -1
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js +77 -13
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
- package/dist-server/server/routes/agent.js +35 -7
- package/dist-server/server/routes/agent.js.map +1 -1
- package/dist-server/server/routes/settings.js +1 -1
- package/dist-server/server/routes/settings.js.map +1 -1
- package/dist-server/server/services/notification-orchestrator.js +1 -1
- package/dist-server/server/services/notification-orchestrator.js.map +1 -1
- package/dist-server/server/shared/utils.js +60 -9
- package/dist-server/server/shared/utils.js.map +1 -1
- package/package.json +1 -1
- package/server/cursor-cli.js +33 -15
- package/server/gemini-cli.js +48 -28
- package/server/index.js +21 -17
- package/server/modules/database/repositories/push-subscriptions.ts +14 -5
- package/server/modules/projects/projects.routes.ts +16 -0
- package/server/modules/projects/services/project-authorization.service.ts +70 -0
- package/server/modules/projects/services/project-clone.service.ts +18 -4
- package/server/modules/projects/services/project-management.service.ts +12 -3
- package/server/modules/projects/tests/project-authorization.service.test.ts +68 -0
- package/server/modules/projects/tests/project-clone.service.test.ts +11 -1
- package/server/modules/projects/tests/project-management.service.test.ts +38 -10
- package/server/modules/websocket/services/chat-websocket.service.ts +87 -19
- package/server/routes/agent.js +34 -7
- package/server/routes/settings.js +1 -1
- package/server/services/notification-orchestrator.js +1 -1
- package/server/shared/utils.ts +70 -9
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { WebSocket } from 'ws';
|
|
2
2
|
|
|
3
|
+
import { assertUserOwnsProjectPath } from '@/modules/projects/services/project-authorization.service.js';
|
|
3
4
|
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
|
|
4
5
|
import { WebSocketWriter } from '@/modules/websocket/services/websocket-writer.service.js';
|
|
5
6
|
import type {
|
|
@@ -7,7 +8,7 @@ import type {
|
|
|
7
8
|
AuthenticatedWebSocketRequest,
|
|
8
9
|
LLMProvider,
|
|
9
10
|
} from '@/shared/types.js';
|
|
10
|
-
import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
|
|
11
|
+
import { AppError, createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
|
|
11
12
|
|
|
12
13
|
type ChatIncomingMessage = AnyRecord & {
|
|
13
14
|
type?: string;
|
|
@@ -30,9 +31,9 @@ type ChatWebSocketDependencies = {
|
|
|
30
31
|
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
|
31
32
|
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
|
32
33
|
abortClaudeSDKSession: (sessionId: string, userId?: string | number | null) => Promise<boolean>;
|
|
33
|
-
abortCursorSession: (sessionId: string) => boolean;
|
|
34
|
+
abortCursorSession: (sessionId: string, userId?: string | number | null) => boolean;
|
|
34
35
|
abortCodexSession: (sessionId: string, userId?: string | number | null) => boolean;
|
|
35
|
-
abortGeminiSession: (sessionId: string) => boolean;
|
|
36
|
+
abortGeminiSession: (sessionId: string, userId?: string | number | null) => boolean;
|
|
36
37
|
resolveToolApproval: (
|
|
37
38
|
requestId: string,
|
|
38
39
|
payload: {
|
|
@@ -43,15 +44,15 @@ type ChatWebSocketDependencies = {
|
|
|
43
44
|
}
|
|
44
45
|
) => void;
|
|
45
46
|
isClaudeSDKSessionActive: (sessionId: string, userId?: string | number | null) => boolean;
|
|
46
|
-
isCursorSessionActive: (sessionId: string) => boolean;
|
|
47
|
+
isCursorSessionActive: (sessionId: string, userId?: string | number | null) => boolean;
|
|
47
48
|
isCodexSessionActive: (sessionId: string, userId?: string | number | null) => boolean;
|
|
48
|
-
isGeminiSessionActive: (sessionId: string) => boolean;
|
|
49
|
+
isGeminiSessionActive: (sessionId: string, userId?: string | number | null) => boolean;
|
|
49
50
|
reconnectSessionWriter: (sessionId: string, ws: WebSocket, userId?: string | number | null) => boolean;
|
|
50
51
|
getPendingApprovalsForSession: (sessionId: string, userId?: string | number | null) => unknown[];
|
|
51
52
|
getActiveClaudeSDKSessions: (userId?: string | number | null) => unknown;
|
|
52
|
-
getActiveCursorSessions: () => unknown;
|
|
53
|
+
getActiveCursorSessions: (userId?: string | number | null) => unknown;
|
|
53
54
|
getActiveCodexSessions: (userId?: string | number | null) => unknown;
|
|
54
|
-
getActiveGeminiSessions: () => unknown;
|
|
55
|
+
getActiveGeminiSessions: (userId?: string | number | null) => unknown;
|
|
55
56
|
};
|
|
56
57
|
|
|
57
58
|
/**
|
|
@@ -88,6 +89,35 @@ function readRequestUserId(
|
|
|
88
89
|
return null;
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Pulls `cwd` out of the websocket message options and confirms the caller
|
|
94
|
+
* owns it. Returning the normalized path lets the downstream spawn use the
|
|
95
|
+
* canonical form instead of whatever the client happened to send.
|
|
96
|
+
*
|
|
97
|
+
* Without this gate, any authenticated user could spawn a CLI in another
|
|
98
|
+
* user's project (or any path on disk) by setting `options.cwd` directly.
|
|
99
|
+
*/
|
|
100
|
+
function authorizeOptionsCwd(
|
|
101
|
+
userId: string | number | null,
|
|
102
|
+
options: AnyRecord | undefined,
|
|
103
|
+
): { authorized: true; cwd: string } | { authorized: false; error: AppError } {
|
|
104
|
+
try {
|
|
105
|
+
const cwd = assertUserOwnsProjectPath(userId, options?.cwd);
|
|
106
|
+
return { authorized: true, cwd };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof AppError) {
|
|
109
|
+
return { authorized: false, error };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
authorized: false,
|
|
113
|
+
error: new AppError('Failed to authorize working directory', {
|
|
114
|
+
code: 'PROJECT_AUTHORIZATION_FAILED',
|
|
115
|
+
statusCode: 500,
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
91
121
|
/**
|
|
92
122
|
* Handles authenticated chat websocket messages used by the main chat panel.
|
|
93
123
|
*/
|
|
@@ -101,6 +131,15 @@ export function handleChatConnection(
|
|
|
101
131
|
|
|
102
132
|
const writer = new WebSocketWriter(ws, readRequestUserId(request));
|
|
103
133
|
|
|
134
|
+
const sendAuthorizationError = (error: AppError, provider: LLMProvider): void => {
|
|
135
|
+
writer.send({
|
|
136
|
+
type: 'error',
|
|
137
|
+
error: error.message,
|
|
138
|
+
code: error.code,
|
|
139
|
+
provider,
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
104
143
|
ws.on('message', async (rawMessage) => {
|
|
105
144
|
try {
|
|
106
145
|
const parsed = parseIncomingJsonObject(rawMessage);
|
|
@@ -115,32 +154,61 @@ export function handleChatConnection(
|
|
|
115
154
|
}
|
|
116
155
|
|
|
117
156
|
if (messageType === 'claude-command') {
|
|
118
|
-
|
|
157
|
+
const authorization = authorizeOptionsCwd(writer.userId, data.options);
|
|
158
|
+
if (!authorization.authorized) {
|
|
159
|
+
sendAuthorizationError(authorization.error, 'claude');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const sanitizedOptions = { ...(data.options ?? {}), cwd: authorization.cwd };
|
|
163
|
+
await dependencies.queryClaudeSDK(data.command ?? '', sanitizedOptions, writer);
|
|
119
164
|
return;
|
|
120
165
|
}
|
|
121
166
|
|
|
122
167
|
if (messageType === 'cursor-command') {
|
|
123
|
-
|
|
168
|
+
const authorization = authorizeOptionsCwd(writer.userId, data.options);
|
|
169
|
+
if (!authorization.authorized) {
|
|
170
|
+
sendAuthorizationError(authorization.error, 'cursor');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const sanitizedOptions = { ...(data.options ?? {}), cwd: authorization.cwd };
|
|
174
|
+
await dependencies.spawnCursor(data.command ?? '', sanitizedOptions, writer);
|
|
124
175
|
return;
|
|
125
176
|
}
|
|
126
177
|
|
|
127
178
|
if (messageType === 'codex-command') {
|
|
128
|
-
|
|
179
|
+
const authorization = authorizeOptionsCwd(writer.userId, data.options);
|
|
180
|
+
if (!authorization.authorized) {
|
|
181
|
+
sendAuthorizationError(authorization.error, 'codex');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const sanitizedOptions = { ...(data.options ?? {}), cwd: authorization.cwd };
|
|
185
|
+
await dependencies.queryCodex(data.command ?? '', sanitizedOptions, writer);
|
|
129
186
|
return;
|
|
130
187
|
}
|
|
131
188
|
|
|
132
189
|
if (messageType === 'gemini-command') {
|
|
133
|
-
|
|
190
|
+
const authorization = authorizeOptionsCwd(writer.userId, data.options);
|
|
191
|
+
if (!authorization.authorized) {
|
|
192
|
+
sendAuthorizationError(authorization.error, 'gemini');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const sanitizedOptions = { ...(data.options ?? {}), cwd: authorization.cwd };
|
|
196
|
+
await dependencies.spawnGemini(data.command ?? '', sanitizedOptions, writer);
|
|
134
197
|
return;
|
|
135
198
|
}
|
|
136
199
|
|
|
137
200
|
if (messageType === 'cursor-resume') {
|
|
201
|
+
const authorization = authorizeOptionsCwd(writer.userId, data.options);
|
|
202
|
+
if (!authorization.authorized) {
|
|
203
|
+
sendAuthorizationError(authorization.error, 'cursor');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
138
206
|
await dependencies.spawnCursor(
|
|
139
207
|
'',
|
|
140
208
|
{
|
|
141
209
|
sessionId: data.sessionId,
|
|
142
210
|
resume: true,
|
|
143
|
-
cwd:
|
|
211
|
+
cwd: authorization.cwd,
|
|
144
212
|
},
|
|
145
213
|
writer
|
|
146
214
|
);
|
|
@@ -154,11 +222,11 @@ export function handleChatConnection(
|
|
|
154
222
|
let success = false;
|
|
155
223
|
|
|
156
224
|
if (provider === 'cursor') {
|
|
157
|
-
success = dependencies.abortCursorSession(sessionId);
|
|
225
|
+
success = dependencies.abortCursorSession(sessionId, userId);
|
|
158
226
|
} else if (provider === 'codex') {
|
|
159
227
|
success = dependencies.abortCodexSession(sessionId, userId);
|
|
160
228
|
} else if (provider === 'gemini') {
|
|
161
|
-
success = dependencies.abortGeminiSession(sessionId);
|
|
229
|
+
success = dependencies.abortGeminiSession(sessionId, userId);
|
|
162
230
|
} else {
|
|
163
231
|
success = await dependencies.abortClaudeSDKSession(sessionId, userId);
|
|
164
232
|
}
|
|
@@ -190,7 +258,7 @@ export function handleChatConnection(
|
|
|
190
258
|
|
|
191
259
|
if (messageType === 'cursor-abort') {
|
|
192
260
|
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
193
|
-
const success = dependencies.abortCursorSession(sessionId);
|
|
261
|
+
const success = dependencies.abortCursorSession(sessionId, writer.userId);
|
|
194
262
|
writer.send(
|
|
195
263
|
createNormalizedMessage({
|
|
196
264
|
kind: 'complete',
|
|
@@ -211,11 +279,11 @@ export function handleChatConnection(
|
|
|
211
279
|
let isActive = false;
|
|
212
280
|
|
|
213
281
|
if (provider === 'cursor') {
|
|
214
|
-
isActive = dependencies.isCursorSessionActive(sessionId);
|
|
282
|
+
isActive = dependencies.isCursorSessionActive(sessionId, userId);
|
|
215
283
|
} else if (provider === 'codex') {
|
|
216
284
|
isActive = dependencies.isCodexSessionActive(sessionId, userId);
|
|
217
285
|
} else if (provider === 'gemini') {
|
|
218
|
-
isActive = dependencies.isGeminiSessionActive(sessionId);
|
|
286
|
+
isActive = dependencies.isGeminiSessionActive(sessionId, userId);
|
|
219
287
|
} else {
|
|
220
288
|
isActive = dependencies.isClaudeSDKSessionActive(sessionId, userId);
|
|
221
289
|
if (isActive) {
|
|
@@ -252,9 +320,9 @@ export function handleChatConnection(
|
|
|
252
320
|
type: 'active-sessions',
|
|
253
321
|
sessions: {
|
|
254
322
|
claude: dependencies.getActiveClaudeSDKSessions(userId),
|
|
255
|
-
cursor: dependencies.getActiveCursorSessions(),
|
|
323
|
+
cursor: dependencies.getActiveCursorSessions(userId),
|
|
256
324
|
codex: dependencies.getActiveCodexSessions(userId),
|
|
257
|
-
gemini: dependencies.getActiveGeminiSessions(),
|
|
325
|
+
gemini: dependencies.getActiveGeminiSessions(userId),
|
|
258
326
|
},
|
|
259
327
|
});
|
|
260
328
|
}
|
package/server/routes/agent.js
CHANGED
|
@@ -12,7 +12,8 @@ import { spawnGemini } from '../gemini-cli.js';
|
|
|
12
12
|
import { Octokit } from '@octokit/rest';
|
|
13
13
|
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
|
14
14
|
import { IS_PLATFORM } from '../constants/config.js';
|
|
15
|
-
import {
|
|
15
|
+
import { assertUserOwnsProjectPath } from '../modules/projects/services/project-authorization.service.js';
|
|
16
|
+
import { AppError, ensureUserWorkspaceRoot, normalizeProjectPath, validateWorkspacePath } from '../shared/utils.js';
|
|
16
17
|
|
|
17
18
|
const router = express.Router();
|
|
18
19
|
|
|
@@ -880,7 +881,17 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
880
881
|
|
|
881
882
|
let targetPath;
|
|
882
883
|
if (projectPath) {
|
|
883
|
-
|
|
884
|
+
// Confine clone destinations to the caller's per-user workspace root so
|
|
885
|
+
// an API key cannot drop a fresh checkout into another user's directory.
|
|
886
|
+
const userWorkspaceRoot = await ensureUserWorkspaceRoot(req.user.username);
|
|
887
|
+
const validation = await validateWorkspacePath(projectPath, userWorkspaceRoot);
|
|
888
|
+
if (!validation.valid) {
|
|
889
|
+
return res.status(403).json({
|
|
890
|
+
success: false,
|
|
891
|
+
error: validation.error || 'Invalid project path for clone destination',
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
targetPath = validation.resolvedPath || projectPath;
|
|
884
895
|
} else {
|
|
885
896
|
// Generate a unique path for cloning
|
|
886
897
|
const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex');
|
|
@@ -889,10 +900,21 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
889
900
|
|
|
890
901
|
finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
|
|
891
902
|
} else {
|
|
892
|
-
//
|
|
893
|
-
|
|
903
|
+
// Existing project path: only the owning user may operate on it.
|
|
904
|
+
try {
|
|
905
|
+
finalProjectPath = assertUserOwnsProjectPath(req.user.id, projectPath);
|
|
906
|
+
} catch (authorizationError) {
|
|
907
|
+
if (authorizationError instanceof AppError) {
|
|
908
|
+
return res.status(authorizationError.statusCode).json({
|
|
909
|
+
success: false,
|
|
910
|
+
error: authorizationError.message,
|
|
911
|
+
code: authorizationError.code,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
throw authorizationError;
|
|
915
|
+
}
|
|
894
916
|
|
|
895
|
-
// Verify the path exists
|
|
917
|
+
// Verify the path exists on disk; ownership check above already confirmed registration.
|
|
896
918
|
try {
|
|
897
919
|
await fs.access(finalProjectPath);
|
|
898
920
|
} catch (error) {
|
|
@@ -902,10 +924,15 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
|
|
902
924
|
|
|
903
925
|
finalProjectPath = normalizeProjectPath(finalProjectPath);
|
|
904
926
|
|
|
905
|
-
//
|
|
927
|
+
// Reject cross-user shared paths outright; previous warn-only behavior let
|
|
928
|
+
// user A operate inside a path also registered to user B.
|
|
906
929
|
const sharedPathCheck = projectsDb.isProjectPathUsedByOthers(req.user.id, finalProjectPath);
|
|
907
930
|
if (sharedPathCheck.used) {
|
|
908
|
-
|
|
931
|
+
return res.status(403).json({
|
|
932
|
+
success: false,
|
|
933
|
+
error: 'Project path is already registered to another user',
|
|
934
|
+
code: 'PROJECT_PATH_CROSS_USER',
|
|
935
|
+
});
|
|
909
936
|
}
|
|
910
937
|
|
|
911
938
|
// Register project path in DB (or reuse existing active registration)
|
|
@@ -255,7 +255,7 @@ router.post('/push/unsubscribe', async (req, res) => {
|
|
|
255
255
|
if (!endpoint) {
|
|
256
256
|
return res.status(400).json({ error: 'Missing endpoint' });
|
|
257
257
|
}
|
|
258
|
-
pushSubscriptionsDb.removeSubscription(endpoint);
|
|
258
|
+
pushSubscriptionsDb.removeSubscription(req.user.id, endpoint);
|
|
259
259
|
|
|
260
260
|
// Disable webPush in preferences to match subscription state
|
|
261
261
|
const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
|
|
@@ -164,7 +164,7 @@ async function sendWebPush(userId, event) {
|
|
|
164
164
|
if (result.status === 'rejected') {
|
|
165
165
|
const statusCode = result.reason?.statusCode;
|
|
166
166
|
if (statusCode === 410 || statusCode === 404) {
|
|
167
|
-
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
|
|
167
|
+
pushSubscriptionsDb.removeSubscription(userId, subscriptions[index].endpoint);
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
});
|
package/server/shared/utils.ts
CHANGED
|
@@ -99,12 +99,66 @@ export class AppError extends Error {
|
|
|
99
99
|
// ---------------------------
|
|
100
100
|
//----------------- WORKSPACE PATH VALIDATION UTILITIES ------------
|
|
101
101
|
/**
|
|
102
|
-
*
|
|
102
|
+
* Shared base directory under which every website user gets their own
|
|
103
|
+
* subdirectory. The per-user root is always `<WORKSPACES_ROOT>/<username>` —
|
|
104
|
+
* even when an operator sets `WORKSPACES_ROOT` explicitly — so that two
|
|
105
|
+
* website users can never browse/create projects in each other's space.
|
|
103
106
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
107
|
+
* Default is `<homedir>/web-code-workspaces` so the app keeps its files in a
|
|
108
|
+
* dedicated directory instead of polluting the host user's home.
|
|
106
109
|
*/
|
|
107
|
-
export const WORKSPACES_ROOT =
|
|
110
|
+
export const WORKSPACES_ROOT =
|
|
111
|
+
process.env.WORKSPACES_ROOT || path.join(os.homedir(), 'web-code-workspaces');
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Strips characters that are unsafe in directory names so a malicious or
|
|
115
|
+
* unusual username cannot escape its workspace sandbox or collide with
|
|
116
|
+
* sibling directories. Returns null if the result is empty/`.`/`..`.
|
|
117
|
+
*/
|
|
118
|
+
export function sanitizeUsernameForPath(username: unknown): string | null {
|
|
119
|
+
if (typeof username !== 'string') {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const trimmed = username.trim();
|
|
124
|
+
if (!trimmed) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sanitized = trimmed.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
129
|
+
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return sanitized;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Returns the absolute per-user workspace root for `username`. Does not touch
|
|
138
|
+
* the filesystem; callers that need the directory to exist before use should
|
|
139
|
+
* call `ensureUserWorkspaceRoot` instead.
|
|
140
|
+
*/
|
|
141
|
+
export function getUserWorkspaceRoot(username: unknown): string {
|
|
142
|
+
const safe = sanitizeUsernameForPath(username);
|
|
143
|
+
if (!safe) {
|
|
144
|
+
throw new AppError('Authenticated user is required', {
|
|
145
|
+
code: 'AUTHENTICATION_REQUIRED',
|
|
146
|
+
statusCode: 401,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return path.join(WORKSPACES_ROOT, safe);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Ensures the per-user workspace directory exists (mkdir -p) and returns its
|
|
154
|
+
* absolute path. Safe to call on every request — `recursive: true` makes it
|
|
155
|
+
* idempotent.
|
|
156
|
+
*/
|
|
157
|
+
export async function ensureUserWorkspaceRoot(username: unknown): Promise<string> {
|
|
158
|
+
const userRoot = getUserWorkspaceRoot(username);
|
|
159
|
+
await mkdir(userRoot, { recursive: true });
|
|
160
|
+
return userRoot;
|
|
161
|
+
}
|
|
108
162
|
|
|
109
163
|
/**
|
|
110
164
|
* System-critical paths that must never be used as workspace roots.
|
|
@@ -201,10 +255,14 @@ export function normalizeProjectPath(inputPath: string): string {
|
|
|
201
255
|
* Validates that a user-supplied workspace path is safe to use.
|
|
202
256
|
*
|
|
203
257
|
* Call this before any filesystem mutation that creates or registers projects.
|
|
204
|
-
* The function resolves symlinks, enforces `
|
|
205
|
-
* blocks known system
|
|
258
|
+
* The function resolves symlinks, enforces containment within `workspaceRoot`
|
|
259
|
+
* (defaults to the app-wide `WORKSPACES_ROOT`), and blocks known system
|
|
260
|
+
* directories. Pass the per-user root for multi-user isolation.
|
|
206
261
|
*/
|
|
207
|
-
export async function validateWorkspacePath(
|
|
262
|
+
export async function validateWorkspacePath(
|
|
263
|
+
requestedPath: string,
|
|
264
|
+
workspaceRoot: string = WORKSPACES_ROOT,
|
|
265
|
+
): Promise<WorkspacePathValidationResult> {
|
|
208
266
|
try {
|
|
209
267
|
const normalizedRequestedPath = normalizeProjectPath(requestedPath);
|
|
210
268
|
if (!normalizedRequestedPath) {
|
|
@@ -267,14 +325,17 @@ export async function validateWorkspacePath(requestedPath: string): Promise<Work
|
|
|
267
325
|
}
|
|
268
326
|
}
|
|
269
327
|
|
|
270
|
-
|
|
328
|
+
// Ensure the workspace root exists before realpath so we can give a clean
|
|
329
|
+
// error to admins who haven't created it yet. Cheap & idempotent.
|
|
330
|
+
await mkdir(workspaceRoot, { recursive: true });
|
|
331
|
+
const resolvedWorkspaceRoot = normalizeProjectPath(await realpath(workspaceRoot));
|
|
271
332
|
if (
|
|
272
333
|
!resolvedPath.startsWith(`${resolvedWorkspaceRoot}${path.sep}`)
|
|
273
334
|
&& resolvedPath !== resolvedWorkspaceRoot
|
|
274
335
|
) {
|
|
275
336
|
return {
|
|
276
337
|
valid: false,
|
|
277
|
-
error: `Workspace path must be within the allowed workspace root: ${
|
|
338
|
+
error: `Workspace path must be within the allowed workspace root: ${workspaceRoot}`,
|
|
278
339
|
};
|
|
279
340
|
}
|
|
280
341
|
|