@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.
Files changed (51) hide show
  1. package/dist/assets/{index-u6XmIqLb.js → index-CfT-2Nkf.js} +2 -2
  2. package/dist/index.html +1 -1
  3. package/dist-server/server/cursor-cli.js +33 -15
  4. package/dist-server/server/cursor-cli.js.map +1 -1
  5. package/dist-server/server/gemini-cli.js +48 -29
  6. package/dist-server/server/gemini-cli.js.map +1 -1
  7. package/dist-server/server/index.js +20 -17
  8. package/dist-server/server/index.js.map +1 -1
  9. package/dist-server/server/modules/database/repositories/push-subscriptions.js +11 -5
  10. package/dist-server/server/modules/database/repositories/push-subscriptions.js.map +1 -1
  11. package/dist-server/server/modules/projects/projects.routes.js +14 -0
  12. package/dist-server/server/modules/projects/projects.routes.js.map +1 -1
  13. package/dist-server/server/modules/projects/services/project-authorization.service.js +54 -0
  14. package/dist-server/server/modules/projects/services/project-authorization.service.js.map +1 -0
  15. package/dist-server/server/modules/projects/services/project-clone.service.js +13 -4
  16. package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
  17. package/dist-server/server/modules/projects/services/project-management.service.js +4 -2
  18. package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
  19. package/dist-server/server/modules/projects/tests/project-authorization.service.test.js +51 -0
  20. package/dist-server/server/modules/projects/tests/project-authorization.service.test.js.map +1 -0
  21. package/dist-server/server/modules/projects/tests/project-clone.service.test.js +10 -1
  22. package/dist-server/server/modules/projects/tests/project-clone.service.test.js.map +1 -1
  23. package/dist-server/server/modules/projects/tests/project-management.service.test.js +31 -10
  24. package/dist-server/server/modules/projects/tests/project-management.service.test.js.map +1 -1
  25. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +77 -13
  26. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  27. package/dist-server/server/routes/agent.js +35 -7
  28. package/dist-server/server/routes/agent.js.map +1 -1
  29. package/dist-server/server/routes/settings.js +1 -1
  30. package/dist-server/server/routes/settings.js.map +1 -1
  31. package/dist-server/server/services/notification-orchestrator.js +1 -1
  32. package/dist-server/server/services/notification-orchestrator.js.map +1 -1
  33. package/dist-server/server/shared/utils.js +60 -9
  34. package/dist-server/server/shared/utils.js.map +1 -1
  35. package/package.json +1 -1
  36. package/server/cursor-cli.js +33 -15
  37. package/server/gemini-cli.js +48 -28
  38. package/server/index.js +21 -17
  39. package/server/modules/database/repositories/push-subscriptions.ts +14 -5
  40. package/server/modules/projects/projects.routes.ts +16 -0
  41. package/server/modules/projects/services/project-authorization.service.ts +70 -0
  42. package/server/modules/projects/services/project-clone.service.ts +18 -4
  43. package/server/modules/projects/services/project-management.service.ts +12 -3
  44. package/server/modules/projects/tests/project-authorization.service.test.ts +68 -0
  45. package/server/modules/projects/tests/project-clone.service.test.ts +11 -1
  46. package/server/modules/projects/tests/project-management.service.test.ts +38 -10
  47. package/server/modules/websocket/services/chat-websocket.service.ts +87 -19
  48. package/server/routes/agent.js +34 -7
  49. package/server/routes/settings.js +1 -1
  50. package/server/services/notification-orchestrator.js +1 -1
  51. 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
- await dependencies.queryClaudeSDK(data.command ?? '', data.options, writer);
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
- await dependencies.spawnCursor(data.command ?? '', data.options, writer);
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
- await dependencies.queryCodex(data.command ?? '', data.options, writer);
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
- await dependencies.spawnGemini(data.command ?? '', data.options, writer);
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: data.options?.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
  }
@@ -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 { normalizeProjectPath } from '../shared/utils.js';
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
- targetPath = projectPath;
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
- // Use existing project path
893
- finalProjectPath = normalizeProjectPath(path.resolve(projectPath));
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
- // Warn if the same project path is actively used by another user
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
- console.warn(`[WARN] Project path "${finalProjectPath}" is shared with user(s): ${sharedPathCheck.usernames.join(', ')}`);
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
  });
@@ -99,12 +99,66 @@ export class AppError extends Error {
99
99
  // ---------------------------
100
100
  //----------------- WORKSPACE PATH VALIDATION UTILITIES ------------
101
101
  /**
102
- * Root directory that all workspace/project paths must stay under.
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
- * This is resolved from `WORKSPACES_ROOT` when configured; otherwise it falls
105
- * back to the current user's home directory.
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 = process.env.WORKSPACES_ROOT || os.homedir();
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 `WORKSPACES_ROOT` containment, and
205
- * blocks known system directories.
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(requestedPath: string): Promise<WorkspacePathValidationResult> {
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
- const resolvedWorkspaceRoot = normalizeProjectPath(await realpath(WORKSPACES_ROOT));
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: ${WORKSPACES_ROOT}`,
338
+ error: `Workspace path must be within the allowed workspace root: ${workspaceRoot}`,
278
339
  };
279
340
  }
280
341