clay-server 4.3.0-beta.9 → 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,
@@ -358,10 +358,15 @@
358
358
  background: rgba(var(--overlay-rgb), 0.06);
359
359
  }
360
360
 
361
- /* Primary creation action + a labeled provider selector. */
361
+ .mobile-session-new:focus-visible {
362
+ outline: 2px solid var(--accent);
363
+ outline-offset: 2px;
364
+ }
365
+
366
+ /* Primary creation action with an adjacent icon-only provider selector. */
362
367
  .mobile-session-new-row {
363
368
  display: flex;
364
- gap: 6px;
369
+ gap: 0;
365
370
  padding: 6px 8px 8px;
366
371
  }
367
372
 
@@ -369,36 +374,43 @@
369
374
  flex: 1 1 auto;
370
375
  width: auto;
371
376
  margin-bottom: 0;
377
+ min-width: 0;
372
378
  padding: 10px 12px;
373
379
  border: 1px solid var(--border-subtle);
374
380
  border-radius: 8px;
375
381
  background: rgba(var(--overlay-rgb), 0.03);
376
382
  color: var(--text);
377
383
  gap: 8px;
384
+ border-radius: 8px 0 0 8px;
385
+ white-space: normal;
386
+ line-height: 1.2;
378
387
  }
379
388
  .mobile-session-new-row .mobile-session-new-provider {
380
- flex: 0 1 132px;
381
- width: 132px;
382
- min-width: 104px;
389
+ flex: 0 0 34px;
390
+ width: 34px;
391
+ min-width: 34px;
383
392
  margin-bottom: 0;
384
- padding: 6px 9px;
393
+ padding: 0;
385
394
  border: 1px solid var(--border-subtle);
386
- border-radius: 8px;
395
+ border-left-color: var(--border);
396
+ border-radius: 0 8px 8px 0;
387
397
  background: rgba(var(--overlay-rgb), 0.03);
388
398
  color: var(--text-muted);
389
- display: grid;
390
- gap: 2px;
391
- }
392
- .mobile-session-new-row .mobile-session-new-provider.expanded .session-create-provider-chevron svg {
393
- transform: rotate(180deg);
399
+ display: inline-flex;
400
+ align-items: center;
401
+ gap: 0;
402
+ justify-content: center;
394
403
  }
395
404
  .mobile-session-new-row .mobile-session-new-main:active,
396
405
  .mobile-session-new-row .mobile-session-new-provider:active {
397
406
  background: rgba(var(--overlay-rgb), 0.1);
398
407
  }
399
408
 
400
- .mobile-session-new-row .session-create-provider-label { font-size: 9px; }
401
- .mobile-session-new-row .session-create-provider-name { font-size: 12px; }
409
+ .mobile-session-new-row .session-create-provider-chevron .lucide,
410
+ .mobile-session-new-row .session-create-provider-chevron svg {
411
+ width: 14px;
412
+ height: 14px;
413
+ }
402
414
 
403
415
  .mobile-vendor-list {
404
416
  display: flex;
@@ -1262,77 +1262,81 @@
1262
1262
  opacity: 1;
1263
1263
  }
1264
1264
 
1265
- /* Primary creation action + a visibly grouped provider selector. */
1265
+ .session-top-action:focus-visible {
1266
+ outline: 2px solid var(--accent);
1267
+ outline-offset: 2px;
1268
+ }
1269
+
1270
+ /* Primary creation action with an adjacent icon-only provider selector. */
1266
1271
  .session-top-action-split {
1267
1272
  display: flex;
1268
- flex-wrap: wrap;
1273
+ flex-wrap: nowrap;
1269
1274
  align-items: stretch;
1270
- gap: 6px;
1275
+ gap: 0;
1271
1276
  min-width: 0;
1272
1277
  }
1273
1278
 
1274
1279
  .session-top-action-split .split-main {
1275
- flex: 1 1 110px;
1280
+ flex: 1 1 auto;
1276
1281
  width: auto;
1277
- min-width: 110px;
1278
- border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent);
1279
- background: color-mix(in srgb, var(--accent) 13%, transparent);
1282
+ min-width: 0;
1283
+ padding: 0 9px;
1284
+ gap: 6px;
1285
+ font-size: 12px;
1286
+ line-height: 1.2;
1287
+ border: 1px solid var(--border-subtle);
1288
+ background: color-mix(in srgb, var(--sidebar-hover) 46%, transparent);
1280
1289
  color: var(--text);
1281
- font-weight: 600;
1282
- white-space: nowrap;
1290
+ font-weight: 500;
1291
+ white-space: normal;
1292
+ text-align: left;
1293
+ border-radius: 9px 0 0 9px;
1283
1294
  }
1284
1295
 
1285
1296
  .session-top-action-split .split-main:hover {
1286
- border-color: color-mix(in srgb, var(--accent) 50%, transparent);
1287
- background: color-mix(in srgb, var(--accent) 19%, transparent);
1297
+ border-color: var(--border);
1298
+ background: var(--sidebar-hover);
1288
1299
  }
1289
1300
 
1290
1301
  .session-top-action-split .split-provider {
1291
- flex: 1 1 104px;
1292
- width: auto;
1293
- min-width: 104px;
1294
- padding: 5px 8px;
1302
+ flex: 0 0 26px;
1303
+ width: 26px;
1304
+ min-width: 26px;
1305
+ padding: 0;
1295
1306
  border: 1px solid var(--border-subtle);
1296
- background: color-mix(in srgb, var(--sidebar-hover) 48%, transparent);
1297
- border-radius: 9px;
1298
- display: grid;
1299
- gap: 1px;
1300
- }
1301
-
1302
- .session-create-provider-label {
1303
- display: block;
1304
- color: var(--text-dimmer);
1305
- font-size: 9px;
1306
- font-weight: 600;
1307
- line-height: 1;
1308
- letter-spacing: 0.03em;
1309
- text-transform: uppercase;
1307
+ border-left-color: var(--border);
1308
+ background: color-mix(in srgb, var(--sidebar-hover) 46%, transparent);
1309
+ border-radius: 0 9px 9px 0;
1310
+ display: inline-flex;
1311
+ align-items: center;
1312
+ gap: 2px;
1313
+ justify-content: center;
1310
1314
  }
1311
1315
 
1312
- .session-create-provider-choice {
1313
- display: flex;
1314
- align-items: center;
1315
- gap: 5px;
1316
- min-width: 0;
1316
+ .session-top-action-split .split-provider:hover {
1317
+ background: var(--sidebar-hover);
1317
1318
  }
1318
1319
 
1319
- .session-create-provider-name {
1320
- overflow: hidden;
1321
- color: var(--text-secondary);
1322
- font-size: 11px;
1323
- font-weight: 600;
1324
- line-height: 1.2;
1325
- text-overflow: ellipsis;
1326
- white-space: nowrap;
1320
+ .session-create-provider-icon {
1321
+ width: 16px;
1322
+ height: 16px;
1323
+ margin-right: 0;
1324
+ border-radius: 4px;
1325
+ object-fit: cover;
1327
1326
  }
1328
1327
 
1329
1328
  .session-create-provider-chevron {
1330
1329
  display: inline-flex;
1331
- margin-left: auto;
1332
1330
  }
1333
1331
 
1334
- .session-create-provider[aria-expanded="true"] .session-create-provider-chevron svg {
1335
- transform: rotate(180deg);
1332
+ .session-create-provider-chevron .lucide,
1333
+ .session-create-provider-chevron svg {
1334
+ width: 14px;
1335
+ height: 14px;
1336
+ }
1337
+
1338
+ .session-create-provider[aria-expanded="true"] {
1339
+ background: var(--sidebar-hover);
1336
1340
  }
1337
1341
 
1338
1342
  .session-favorites-section {
@@ -2,6 +2,19 @@
2
2
 
3
3
  import { iconHtml } from './icons.js';
4
4
 
5
+ var SESSION_VENDOR_LABELS = {
6
+ claude: "Claude",
7
+ codex: "Codex",
8
+ antigravity: "Antigravity",
9
+ opencode: "OpenCode",
10
+ kimi: "Kimi",
11
+ grok: "Grok",
12
+ copilot: "Copilot",
13
+ qwen: "Qwen",
14
+ junie: "Junie",
15
+ kiro: "Kiro",
16
+ };
17
+
5
18
  export function renderSessionCreationCta(options) {
6
19
  var row = document.createElement("div");
7
20
  row.className = options.rowClass + " session-create-cta";
@@ -9,7 +22,10 @@ export function renderSessionCreationCta(options) {
9
22
  var createButton = document.createElement("button");
10
23
  createButton.className = options.createClass + " session-create-primary";
11
24
  createButton.type = "button";
12
- createButton.textContent = "Create session";
25
+ var vendorLabel = SESSION_VENDOR_LABELS[options.vendorKey] || options.vendorName;
26
+ createButton.innerHTML = '<img src="' + options.vendorAvatar +
27
+ '" class="' + options.iconClass + ' session-create-provider-icon" alt="">' +
28
+ '<span>Create new ' + vendorLabel + ' session</span>';
13
29
  createButton.title = options.createTitle;
14
30
  createButton.setAttribute("aria-label", options.createTitle);
15
31
  createButton.addEventListener("click", options.onCreate);
@@ -19,13 +35,10 @@ export function renderSessionCreationCta(options) {
19
35
  providerButton.className = options.providerClass + " session-create-provider";
20
36
  providerButton.type = "button";
21
37
  providerButton.title = options.providerTitle;
22
- providerButton.setAttribute("aria-label", "AI provider: " + options.vendorName + ". " + options.providerTitle);
38
+ providerButton.setAttribute("aria-label", "Switch AI provider (current: " + options.vendorName + ")");
23
39
  providerButton.setAttribute("aria-expanded", "false");
24
40
  providerButton.setAttribute("aria-controls", options.menuId);
25
- providerButton.innerHTML = '<span class="session-create-provider-label">AI provider</span>' +
26
- '<span class="session-create-provider-choice"><img src="' + options.vendorAvatar +
27
- '" class="' + options.iconClass + ' session-create-provider-icon" alt=""><span class="session-create-provider-name">' +
28
- options.vendorName + '</span>' + iconHtml("chevron-down", "session-create-provider-chevron") + '</span>';
41
+ providerButton.innerHTML = iconHtml("chevron-down", "session-create-provider-chevron");
29
42
  providerButton.addEventListener("click", options.onProvider);
30
43
  row.appendChild(providerButton);
31
44
 
@@ -689,10 +689,11 @@ export function renderMobileSessionsInto(container) {
689
689
  createClass: "mobile-session-new mobile-session-new-main",
690
690
  providerClass: "mobile-session-new mobile-session-new-provider",
691
691
  iconClass: "mobile-session-new-icon",
692
+ vendorKey: mobileDefaultVendor,
692
693
  vendorName: mobileVendorName,
693
694
  vendorAvatar: VENDOR_AVATARS[mobileDefaultVendor] || VENDOR_AVATARS.claude,
694
695
  createTitle: mobileCreateTitle,
695
- providerTitle: "Choose an AI provider",
696
+ providerTitle: "Switch AI provider (current: " + mobileVendorName + ")",
696
697
  menuId: vendorList.id,
697
698
  onCreate: function () { startNewSession(mobileDefaultVendor); closeMobileSheet(); },
698
699
  onProvider: function () {
@@ -372,10 +372,11 @@ export function renderSessionTopActions() {
372
372
  createClass: "session-top-action split-main",
373
373
  providerClass: "session-top-action split-provider",
374
374
  iconClass: "session-top-action-icon",
375
+ vendorKey: defaultVendor,
375
376
  vendorName: vendorName,
376
377
  vendorAvatar: VENDOR_AVATARS[defaultVendor] || VENDOR_AVATARS.claude,
377
378
  createTitle: createTitle,
378
- providerTitle: "Choose an AI provider or launch mode",
379
+ providerTitle: "Switch AI provider (current: " + vendorName + ")",
379
380
  menuId: "session-new-menu",
380
381
  onCreate: function () { startNewSession(defaultVendor); },
381
382
  onProvider: function (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.3.0-beta.9",
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"