clay-server 4.3.0 → 4.3.1-beta.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.
@@ -0,0 +1,56 @@
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var attachRequestAccess = require("./project-request-access").attachRequestAccess;
4
+ var resolveFilePath = require("./project-file-path").resolveFilePath;
5
+ var fileError = require("./project-file-path").fileError;
6
+
7
+ var MIME_TYPES = {
8
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
9
+ ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
10
+ ".bmp": "image/bmp", ".ico": "image/x-icon",
11
+ };
12
+
13
+ function attachFileHTTP(ctx) {
14
+ var access = ctx.requestAccess || attachRequestAccess(Object.assign({}, ctx, {
15
+ getOsUserInfoForWs: ctx.getOsUserInfoForReq,
16
+ }));
17
+
18
+ function handleHTTP(req, res, urlPath) {
19
+ var download = urlPath.startsWith("/api/file/download?");
20
+ if (req.method !== "GET" || (!download && !urlPath.startsWith("/api/file?"))) return false;
21
+ try {
22
+ var scope = access.fileScope(req);
23
+ var requested = new URLSearchParams(urlPath.substring(urlPath.indexOf("?"))).get("path");
24
+ var file = resolveFilePath(ctx.cwd, requested, scope);
25
+ var mime = MIME_TYPES[path.extname(file).toLowerCase()];
26
+ if (!download && !mime) {
27
+ res.writeHead(403); res.end("Only image files"); return true;
28
+ }
29
+ var content = scope.identity ? ctx.fsAsUser("read_binary", { file: file }, scope.identity).buffer : fs.readFileSync(file);
30
+ var headers = {
31
+ "Content-Type": download ? "application/octet-stream" : mime,
32
+ "Content-Length": content.length,
33
+ "Cache-Control": "no-store",
34
+ "X-Content-Type-Options": "nosniff",
35
+ };
36
+ if (download) {
37
+ var name = path.basename(requested).replace(/[\x00-\x1f\x7f"\\]/g, "_") || "download";
38
+ var ascii = name.replace(/[^\x20-\x7e]/g, "_");
39
+ var encoded = encodeURIComponent(name).replace(/[!'()*]/g, function (character) {
40
+ return "%" + character.charCodeAt(0).toString(16).toUpperCase();
41
+ });
42
+ headers["Content-Disposition"] = "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + encoded;
43
+ }
44
+ res.writeHead(200, headers);
45
+ res.end(content);
46
+ } catch (error) {
47
+ var result = fileError(error);
48
+ res.writeHead(result.status, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
49
+ res.end(result.message);
50
+ }
51
+ return true;
52
+ }
53
+ return { handleHTTP: handleHTTP };
54
+ }
55
+
56
+ module.exports = { attachFileHTTP: attachFileHTTP };
@@ -1,13 +1,49 @@
1
1
  var path = require("path");
2
+ var fs = require("fs");
2
3
 
3
- // Resolve a file-browser target in the caller's project namespace. Ordinary
4
- // mode delegates to safePath, which enforces the realpath/symlink boundary.
5
- // OS-user mode deliberately returns the project-relative absolute target and
6
- // leaves access enforcement to the mapped user's filesystem operation.
7
- function resolveFilePath(cwd, requested, safePath, osUserInfo) {
8
- if (typeof requested !== "string" || !requested) return null;
9
- if (osUserInfo) return path.resolve(cwd, requested);
10
- return typeof safePath === "function" ? safePath(cwd, requested) : null;
4
+ function failure(code, message) {
5
+ var error = new Error(message);
6
+ error.code = code;
7
+ return error;
11
8
  }
12
9
 
13
- module.exports = { resolveFilePath: resolveFilePath };
10
+ function inside(base, target) {
11
+ var relative = path.relative(base, target);
12
+ return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
13
+ }
14
+
15
+ // Scope is server-derived for each request. In personal and mapped OS-user
16
+ // modes, the actual filesystem operation enforces read/write permissions.
17
+ // Unmapped multi-user deployments retain the project and symlink boundary.
18
+ function resolveFilePath(cwd, requested, scope) {
19
+ if (typeof requested !== "string" || !requested || requested.indexOf("\0") !== -1) {
20
+ throw failure("INVALID_PATH", "Invalid file path");
21
+ }
22
+ var resolved = path.resolve(cwd, requested);
23
+ if (scope && scope.projectBound === false) return resolved;
24
+ if (!inside(path.resolve(cwd), resolved)) {
25
+ throw failure("FILE_SCOPE", "This file is outside your project's allowed file scope");
26
+ }
27
+ var base = fs.realpathSync(cwd);
28
+ var real = fs.realpathSync(resolved);
29
+ if (!inside(base, real)) {
30
+ throw failure("FILE_SCOPE", "This file links outside your project's allowed file scope");
31
+ }
32
+ return real;
33
+ }
34
+
35
+ function fileError(error) {
36
+ var code = error.code;
37
+ // OS-user helpers report child-process failures through stderr.
38
+ if (error.stderr) {
39
+ var match = String(error.stderr).match(/code: ['"](EACCES|EPERM|ENOENT|ENOTDIR)['"]/);
40
+ if (match) code = match[1];
41
+ }
42
+ if (code === "ENOENT" || code === "ENOTDIR") return { code: code, status: 404, message: "File or folder not found" };
43
+ if (code === "EACCES" || code === "EPERM") return { code: code, status: 403, message: "The operating system denied access for your account" };
44
+ if (code === "INVALID_PATH") return { code: code, status: 400, message: error.message };
45
+ if (code === "FILE_SCOPE" || code === "FILE_FORBIDDEN" || code === "OS_IDENTITY") return { code: code, status: 403, message: error.message };
46
+ return { code: code || "FILE_ERROR", status: 500, message: error.message || "File operation failed" };
47
+ }
48
+
49
+ module.exports = { resolveFilePath: resolveFilePath, fileError: fileError, failure: failure };
@@ -6,34 +6,28 @@ var resolveFilePath = require("./project-file-path").resolveFilePath;
6
6
  * Attach file/directory watcher engine to a project context.
7
7
  *
8
8
  * ctx fields:
9
- * cwd, send, sendTo, safePath, BINARY_EXTS, FS_MAX_SIZE, IGNORED_DIRS, requestAccess, fsAsUser
9
+ * cwd, send, sendTo, BINARY_EXTS, FS_MAX_SIZE, IGNORED_DIRS, requestAccess, fsAsUser
10
10
  */
11
11
  function attachFileWatch(ctx) {
12
12
  var cwd = ctx.cwd;
13
13
  var send = ctx.send;
14
14
  var sendTo = ctx.sendTo;
15
- var safePath = ctx.safePath;
16
15
  var BINARY_EXTS = ctx.BINARY_EXTS;
17
16
  var FS_MAX_SIZE = ctx.FS_MAX_SIZE;
18
17
  var IGNORED_DIRS = ctx.IGNORED_DIRS;
19
18
  var access = ctx.requestAccess;
20
19
  var osUsers = !!ctx.osUsers;
21
20
 
22
- function identityFor(client) {
21
+ function scopeFor(client) {
23
22
  if (!access) {
24
23
  if (osUsers) throw new Error("OS user identity is unavailable");
25
- return null;
24
+ return { identity: null, projectBound: true };
26
25
  }
27
- if (!access.canUseFiles(client)) throw new Error("File browser access is not permitted");
28
- var identity = access.osIdentity(client);
29
- if (osUsers && !identity) throw new Error("OS user identity is unavailable");
30
- return identity;
26
+ return access.fileScope(client);
31
27
  }
32
28
 
33
- function checkedPath(relPath, identity) {
34
- var resolved = resolveFilePath(cwd, relPath, safePath, osUsers ? identity : null);
35
- if (!resolved) throw new Error("Access denied");
36
- return resolved;
29
+ function checkedPath(relPath, scope) {
30
+ return resolveFilePath(cwd, relPath, scope);
37
31
  }
38
32
 
39
33
  // --- File watcher ---
@@ -68,8 +62,9 @@ function attachFileWatch(ctx) {
68
62
  }
69
63
 
70
64
  function readFileSnapshot(client, relPath) {
71
- var identity = identityFor(client);
72
- var absPath = checkedPath(relPath, identity);
65
+ var scope = scopeFor(client);
66
+ var identity = scope.identity;
67
+ var absPath = checkedPath(relPath, scope);
73
68
  var stat = identity ? ctx.fsAsUser("stat", { file: absPath }, identity) : fs.statSync(absPath);
74
69
  var ext = path.extname(absPath).toLowerCase();
75
70
  if (stat.size > FS_MAX_SIZE || BINARY_EXTS.has(ext)) return null;
@@ -107,10 +102,8 @@ function attachFileWatch(ctx) {
107
102
  relPath = client;
108
103
  client = null;
109
104
  }
110
- var identity;
111
- try { identity = identityFor(client); } catch (error) { return Promise.resolve(false); }
112
- var absPath = resolveFilePath(cwd, relPath, safePath, osUsers ? identity : null);
113
- if (!absPath) return Promise.resolve(false);
105
+ var absPath;
106
+ try { absPath = checkedPath(relPath, scopeFor(client)); } catch (error) { return Promise.resolve(false); }
114
107
  var key = client || "_legacy";
115
108
  var existing = fileWatchers.get(key);
116
109
  if (existing && existing.relPath === relPath) return existing.ready;
@@ -186,8 +179,9 @@ function attachFileWatch(ctx) {
186
179
  var dirWatchers = new Map();
187
180
 
188
181
  function readDirectory(client, relPath) {
189
- var identity = identityFor(client);
190
- var absPath = checkedPath(relPath, identity);
182
+ var scope = scopeFor(client);
183
+ var identity = scope.identity;
184
+ var absPath = checkedPath(relPath, scope);
191
185
  var items = identity ? ctx.fsAsUser("list", { dir: absPath }, identity) :
192
186
  fs.readdirSync(absPath, { withFileTypes: true }).map(function (item) {
193
187
  return { name: item.name, isDir: item.isDirectory() };
@@ -204,12 +198,12 @@ function attachFileWatch(ctx) {
204
198
  var subscriptions = dirWatchers.get(client);
205
199
  if (!subscriptions) { subscriptions = new Map(); dirWatchers.set(client, subscriptions); }
206
200
  if (subscriptions.has(relPath)) return;
207
- var identity;
201
+ var scope;
208
202
  try {
209
- identity = identityFor(client);
203
+ scope = scopeFor(client);
210
204
  readDirectory(client, relPath);
211
205
  var entry = { watcher: null, debounce: null };
212
- entry.watcher = fs.watch(checkedPath(relPath, identity), function () {
206
+ entry.watcher = fs.watch(checkedPath(relPath, scope), function () {
213
207
  clearTimeout(entry.debounce);
214
208
  entry.debounce = setTimeout(function () {
215
209
  try {
@@ -3,6 +3,7 @@ var path = require("path");
3
3
  var attachRequestAccess = require("./project-request-access").attachRequestAccess;
4
4
  var attachFileHistory = require("./project-file-history").attachFileHistory;
5
5
  var resolveFilePath = require("./project-file-path").resolveFilePath;
6
+ var fileError = require("./project-file-path").fileError;
6
7
 
7
8
  /**
8
9
  * Attach filesystem-related message handlers to a project context.
@@ -11,7 +12,6 @@ var resolveFilePath = require("./project-file-path").resolveFilePath;
11
12
  * cwd, slug, osUsers
12
13
  * sm (session manager)
13
14
  * send, sendTo
14
- * safePath, safeAbsPath (functions)
15
15
  * getOsUserInfoForWs (function)
16
16
  * startFileWatch, stopFileWatch, startDirWatch (from _fileWatch)
17
17
  * usersModule, fsAsUser
@@ -23,8 +23,6 @@ function attachFilesystem(ctx) {
23
23
  var cwd = ctx.cwd;
24
24
  var slug = ctx.slug;
25
25
  var sendTo = ctx.sendTo;
26
- var safePath = ctx.safePath;
27
- var safeAbsPath = ctx.safeAbsPath;
28
26
  var startFileWatch = ctx.startFileWatch;
29
27
  var stopFileWatch = ctx.stopFileWatch;
30
28
  var startDirWatch = ctx.startDirWatch;
@@ -41,26 +39,26 @@ function attachFilesystem(ctx) {
41
39
  var fileHistory = attachFileHistory(ctx, access);
42
40
 
43
41
  function handleFilesystemMessage(ws, msg) {
44
- // Unsubscription must remain available after permission revocation.
42
+ var fileScope;
43
+ var fileTarget;
44
+ // Resolve live authority before every operation; never trust message roles.
45
45
  if (/^fs_/.test(msg.type) && msg.type !== "fs_unwatch") {
46
- if (!access.canUseFiles(ws)) {
47
- sendTo(ws, { type: msg.type + "_result", path: msg.path, requestId: msg.requestId, projectSlug: msg.projectSlug, sessionId: msg.sessionId, accountId: msg.accountId, error: "File browser access is not permitted" });
48
- return true;
49
- }
50
- try { access.osIdentity(ws); } catch (e) {
51
- sendTo(ws, { type: msg.type + "_result", path: msg.path, requestId: msg.requestId, projectSlug: msg.projectSlug, sessionId: msg.sessionId, accountId: msg.accountId, error: e.message });
46
+ try {
47
+ fileScope = access.fileScope(ws);
48
+ if (/^fs_(list|read|write|watch)$/.test(msg.type)) {
49
+ fileTarget = resolveFilePath(cwd, msg.type === "fs_list" ? msg.path || "." : msg.path, fileScope);
50
+ }
51
+ } catch (e) {
52
+ var denied = fileError(e);
53
+ sendTo(ws, { type: msg.type + "_result", path: msg.path, requestId: msg.requestId, projectSlug: msg.projectSlug, sessionId: msg.sessionId, accountId: msg.accountId, ok: false, error: denied.message, errorCode: denied.code });
52
54
  return true;
53
55
  }
54
56
  }
55
57
 
56
58
  // --- fs_list ---
57
59
  if (msg.type === "fs_list") {
58
- var fsListIdentity = access.osIdentity(ws);
59
- var fsDir = resolveFilePath(cwd, msg.path || ".", safePath, fsListIdentity);
60
- if (!fsDir) {
61
- sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: "Access denied" });
62
- return true;
63
- }
60
+ var fsListIdentity = fileScope.identity;
61
+ var fsDir = fileTarget;
64
62
  try {
65
63
  var fsListUserInfo = fsListIdentity;
66
64
  var entries = [];
@@ -92,7 +90,7 @@ function attachFilesystem(ctx) {
92
90
  // Auto-watch the directory for changes
93
91
  startDirWatch(ws, msg.path || ".");
94
92
  } catch (e) {
95
- sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: e.message });
93
+ sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: fileError(e).message, errorCode: fileError(e).code });
96
94
  }
97
95
  return true;
98
96
  }
@@ -138,19 +136,15 @@ function attachFilesystem(ctx) {
138
136
  walkDir(cwd, "");
139
137
  sendTo(ws, { type: "fs_search_result", query: msg.query, entries: searchResults });
140
138
  } catch (e) {
141
- sendTo(ws, { type: "fs_search_result", query: msg.query, entries: [], error: e.message });
139
+ sendTo(ws, { type: "fs_search_result", query: msg.query, entries: [], error: fileError(e).message, errorCode: fileError(e).code });
142
140
  }
143
141
  return true;
144
142
  }
145
143
 
146
144
  // --- fs_read ---
147
145
  if (msg.type === "fs_read") {
148
- var fsReadIdentity = access.osIdentity(ws);
149
- var fsFile = resolveFilePath(cwd, msg.path, safePath, fsReadIdentity);
150
- if (!fsFile) {
151
- sendTo(ws, { type: "fs_read_result", path: msg.path, requestId: msg.requestId, projectSlug: msg.projectSlug, sessionId: msg.sessionId, accountId: msg.accountId, error: "Access denied" });
152
- return true;
153
- }
146
+ var fsReadIdentity = fileScope.identity;
147
+ var fsFile = fileTarget;
154
148
  try {
155
149
  var fsReadUserInfo = fsReadIdentity;
156
150
  var ext = path.extname(fsFile).toLowerCase();
@@ -185,19 +179,15 @@ function attachFilesystem(ctx) {
185
179
  sendTo(ws, { type: "fs_read_result", path: msg.path, requestId: msg.requestId, projectSlug: msg.projectSlug, sessionId: msg.sessionId, accountId: msg.accountId, content: content, size: stat.size });
186
180
  }
187
181
  } catch (e) {
188
- sendTo(ws, { type: "fs_read_result", path: msg.path, requestId: msg.requestId, projectSlug: msg.projectSlug, sessionId: msg.sessionId, accountId: msg.accountId, error: e.message });
182
+ sendTo(ws, { type: "fs_read_result", path: msg.path, requestId: msg.requestId, projectSlug: msg.projectSlug, sessionId: msg.sessionId, accountId: msg.accountId, error: fileError(e).message, errorCode: fileError(e).code });
189
183
  }
190
184
  return true;
191
185
  }
192
186
 
193
187
  // --- fs_write ---
194
188
  if (msg.type === "fs_write") {
195
- var fsWriteIdentity = access.osIdentity(ws);
196
- var fsWriteFile = resolveFilePath(cwd, msg.path, safePath, fsWriteIdentity);
197
- if (!fsWriteFile) {
198
- sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: "Access denied" });
199
- return true;
200
- }
189
+ var fsWriteIdentity = fileScope.identity;
190
+ var fsWriteFile = fileTarget;
201
191
  try {
202
192
  var fsWriteUserInfo = fsWriteIdentity;
203
193
  if (fsWriteUserInfo) {
@@ -207,7 +197,7 @@ function attachFilesystem(ctx) {
207
197
  }
208
198
  sendTo(ws, { type: "fs_write_result", path: msg.path, ok: true });
209
199
  } catch (e) {
210
- sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: e.message });
200
+ sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: fileError(e).message, errorCode: fileError(e).code });
211
201
  }
212
202
  return true;
213
203
  }
@@ -6,24 +6,9 @@ var { execFileSync, spawn } = require("child_process");
6
6
  var defaultFsAsUser = require("./os-users").fsAsUser;
7
7
  var defaultUsersModule = require("./users");
8
8
  var gitCli = require("./git-cli");
9
- var resolveFilePath = require("./project-file-path").resolveFilePath;
9
+ var attachFileHTTP = require("./project-file-http").attachFileHTTP;
10
10
  var sharedSkillDiscovery = require("./yoke/skill-discovery");
11
11
 
12
- var IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]);
13
- var MIME_TYPES = {
14
- ".html": "text/html",
15
- ".css": "text/css",
16
- ".js": "application/javascript",
17
- ".json": "application/json",
18
- ".png": "image/png",
19
- ".jpg": "image/jpeg",
20
- ".jpeg": "image/jpeg",
21
- ".gif": "image/gif",
22
- ".webp": "image/webp",
23
- ".svg": "image/svg+xml",
24
- ".bmp": "image/bmp",
25
- ".ico": "image/x-icon",
26
- };
27
12
  var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
28
13
 
29
14
  function parseJsonBody(req) {
@@ -42,7 +27,7 @@ function parseJsonBody(req) {
42
27
  *
43
28
  * ctx fields:
44
29
  * cwd, slug, project, sm, send, sendTo, imagesDir, osUsers, pushModule,
45
- * safePath, safeAbsPath, getOsUserInfoForReq, sendExtensionCommandAny,
30
+ * requestAccess, getOsUserInfoForReq, sendExtensionCommandAny,
46
31
  * saveGeneratedImageToProject, _extToken, _browserTabList
47
32
  */
48
33
  function attachHTTP(ctx) {
@@ -56,8 +41,6 @@ function attachHTTP(ctx) {
56
41
  var saveGeneratedImageToProject = ctx.saveGeneratedImageToProject;
57
42
  var osUsers = ctx.osUsers;
58
43
  var pushModule = ctx.pushModule;
59
- var safePath = ctx.safePath;
60
- var safeAbsPath = ctx.safeAbsPath;
61
44
  var getOsUserInfoForReq = ctx.getOsUserInfoForReq;
62
45
  var fsAsUser = ctx.fsAsUser || defaultFsAsUser;
63
46
  var usersModule = ctx.usersModule || defaultUsersModule;
@@ -65,6 +48,10 @@ function attachHTTP(ctx) {
65
48
  var _extToken = ctx._extToken;
66
49
  var _browserTabList = ctx._browserTabList;
67
50
 
51
+ var fileHTTP = attachFileHTTP(Object.assign({}, ctx, {
52
+ usersModule: usersModule, fsAsUser: fsAsUser,
53
+ }));
54
+
68
55
  function handleHTTP(req, res, urlPath) {
69
56
  // Browser MCP extension bridge: forward commands to Chrome extension
70
57
  if (req.method === "POST" && urlPath === "/ext-command") {
@@ -291,92 +278,7 @@ function attachHTTP(ctx) {
291
278
  return true;
292
279
  }
293
280
 
294
- // File browser: download project files
295
- if (req.method === "GET" && urlPath.startsWith("/api/file/download?")) {
296
- if (usersModule.isMultiUser()) {
297
- var downloadUser = req._clayUser;
298
- var downloadPermissions = downloadUser ? usersModule.getEffectivePermissions(downloadUser, osUsers) : null;
299
- if (!downloadPermissions || !downloadPermissions.fileBrowser) {
300
- res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
301
- res.end("File browser access is not permitted");
302
- return true;
303
- }
304
- }
305
-
306
- var downloadQueryIndex = urlPath.indexOf("?");
307
- var downloadParams = new URLSearchParams(urlPath.substring(downloadQueryIndex));
308
- var downloadPath = downloadParams.get("path");
309
- if (!downloadPath) { res.writeHead(400); res.end("Missing path"); return true; }
310
- var downloadUserInfo = getOsUserInfoForReq(req);
311
- if (osUsers && !downloadUserInfo) { res.writeHead(403); res.end("OS user identity unavailable"); return true; }
312
- var downloadFile = resolveFilePath(cwd, downloadPath, safePath, downloadUserInfo);
313
- if (!downloadFile) { res.writeHead(403); res.end("Access denied"); return true; }
314
-
315
- try {
316
- var downloadContent;
317
- if (downloadUserInfo) {
318
- downloadContent = fsAsUser("read_binary", { file: downloadFile }, downloadUserInfo).buffer;
319
- } else {
320
- downloadContent = fs.readFileSync(downloadFile);
321
- }
322
- var downloadName = path.basename(downloadPath).replace(/[\x00-\x1f\x7f"\\]/g, "_") || "download";
323
- var asciiDownloadName = downloadName.replace(/[^\x20-\x7e]/g, "_");
324
- var encodedDownloadName = encodeURIComponent(downloadName).replace(/[!'()*]/g, function (character) {
325
- return "%" + character.charCodeAt(0).toString(16).toUpperCase();
326
- });
327
- var contentDisposition = "attachment; filename=\"" + asciiDownloadName + "\"; filename*=UTF-8''" + encodedDownloadName;
328
- res.writeHead(200, {
329
- "Content-Type": "application/octet-stream",
330
- "Content-Length": downloadContent.length,
331
- "Content-Disposition": contentDisposition,
332
- "Cache-Control": "no-store",
333
- "X-Content-Type-Options": "nosniff",
334
- });
335
- res.end(downloadContent);
336
- } catch (e) {
337
- res.writeHead(404);
338
- res.end("Not found");
339
- }
340
- return true;
341
- }
342
-
343
- // File browser: serve project images
344
- if (req.method === "GET" && urlPath.startsWith("/api/file?")) {
345
- var qIdx = urlPath.indexOf("?");
346
- var params = new URLSearchParams(urlPath.substring(qIdx));
347
- var reqFilePath = params.get("path");
348
- if (!reqFilePath) { res.writeHead(400); res.end("Missing path"); return true; }
349
- if (usersModule.isMultiUser()) {
350
- var imageUser = req._clayUser;
351
- var imagePermissions = imageUser ? usersModule.getEffectivePermissions(imageUser, osUsers) : null;
352
- if (!imagePermissions || !imagePermissions.fileBrowser) {
353
- res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
354
- res.end("File browser access is not permitted");
355
- return true;
356
- }
357
- }
358
- var fileServeUserInfo = getOsUserInfoForReq(req);
359
- if (osUsers && !fileServeUserInfo) { res.writeHead(403); res.end("OS user identity unavailable"); return true; }
360
- var absFile = resolveFilePath(cwd, reqFilePath, safePath, fileServeUserInfo);
361
- if (!absFile) { res.writeHead(403); res.end("Access denied"); return true; }
362
- var fileExt = path.extname(absFile).toLowerCase();
363
- if (!IMAGE_EXTS.has(fileExt)) { res.writeHead(403); res.end("Only image files"); return true; }
364
- try {
365
- var fileContent;
366
- if (fileServeUserInfo) {
367
- var binResult = fsAsUser("read_binary", { file: absFile }, fileServeUserInfo);
368
- fileContent = binResult.buffer;
369
- } else {
370
- fileContent = fs.readFileSync(absFile);
371
- }
372
- var fileMime = MIME_TYPES[fileExt] || "application/octet-stream";
373
- res.writeHead(200, { "Content-Type": fileMime, "Cache-Control": "no-cache" });
374
- res.end(fileContent);
375
- } catch (e) {
376
- res.writeHead(404); res.end("Not found");
377
- }
378
- return true;
379
- }
281
+ if (fileHTTP.handleHTTP(req, res, urlPath)) return true;
380
282
 
381
283
  // Skills permission gate
382
284
  if (urlPath === "/api/install-skill" || urlPath === "/api/uninstall-skill" || urlPath === "/api/installed-skills") {
@@ -1,4 +1,5 @@
1
1
  // Shared authorization for project requests and long-lived file subscriptions.
2
+ var fileFailure = require("./project-file-path").failure;
2
3
  function attachRequestAccess(ctx) {
3
4
  var users = ctx.usersModule;
4
5
  var opts = ctx.opts || {};
@@ -9,7 +10,7 @@ function attachRequestAccess(ctx) {
9
10
 
10
11
  function userFor(ws) {
11
12
  var user = ws && ws._clayUser;
12
- if (user && users.findUserById) return users.findUserById(user.id);
13
+ if (user && users && users.findUserById) return users.findUserById(user.id);
13
14
  return user || null;
14
15
  }
15
16
 
@@ -53,14 +54,20 @@ function attachRequestAccess(ctx) {
53
54
  function osIdentity(ws) {
54
55
  if (!ctx.osUsers) return null;
55
56
  var user = userFor(ws);
56
- if (!user || !user.linuxUser) throw new Error("OS user identity is unavailable");
57
+ if (!user || !user.linuxUser) throw fileFailure("OS_IDENTITY", "OS user identity is unavailable");
57
58
  var info = ctx.getOsUserInfoForWs({ _clayUser: user });
58
- if (!info) throw new Error("OS user identity is unavailable");
59
+ if (!info) throw fileFailure("OS_IDENTITY", "OS user identity is unavailable");
59
60
  return info;
60
61
  }
61
62
 
63
+ function fileScope(ws) {
64
+ if (!canUseFiles(ws)) throw fileFailure("FILE_FORBIDDEN", "File browser access is not permitted");
65
+ var identity = osIdentity(ws);
66
+ return { identity: identity, projectBound: isMultiUser() && !identity };
67
+ }
68
+
62
69
  return { permitMessage: permitMessage, canAccessProject: canAccessProject, hasPermission: hasPermission,
63
- canUseFiles: canUseFiles, isAdmin: isAdmin, canReadSession: canReadSession, osIdentity: osIdentity };
70
+ canUseFiles: canUseFiles, isAdmin: isAdmin, canReadSession: canReadSession, osIdentity: osIdentity, fileScope: fileScope };
64
71
  }
65
72
 
66
73
  module.exports = { attachRequestAccess: attachRequestAccess };
package/lib/project.js CHANGED
@@ -2481,6 +2481,7 @@ function createProjectContext(opts) {
2481
2481
 
2482
2482
  // --- HTTP handler (delegated to project-http.js) ---
2483
2483
  var _http = attachHTTP({
2484
+ requestAccess: _requestAccess,
2484
2485
  cwd: cwd,
2485
2486
  slug: slug,
2486
2487
  project: title || project,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.3.0",
3
+ "version": "4.3.1-beta.1",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"