clay-server 4.3.0 → 4.3.1-beta.2

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") {
@@ -162,6 +162,15 @@ function attachPairLifecycle(ctx) {
162
162
  return null;
163
163
  }
164
164
  function partnerStatus(caller, args) {
165
+ var group = caller && store.groupForMember(caller.localId);
166
+ var roles = splitRoles.normalizePair(group && group.pair, group && group.members);
167
+ if ((!args || args.workerId == null) && roles.ok && roles.kind !== "adhoc" && roles.workerIds.length > 1) {
168
+ var workers = roles.workerIds.map(function (workerId) {
169
+ return partnerStatus(caller, { workerId: workerId });
170
+ });
171
+ return { driverId: caller.localId, workerIds: roles.workerIds.slice(), workers: workers,
172
+ instruction: "Pass the selected workerId to targeted partner tools. Call partner_status without workerId to rediscover current Workers." };
173
+ }
165
174
  var resolved = resolveDriverPair(caller, args, { readOnly: true });
166
175
  var worker = resolved.worker;
167
176
  var blocked = turnControl.blockedReason(caller) || replaceBlockedReason(worker);
@@ -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 };
@@ -13,6 +13,11 @@ function attachSessionPairPrompt(ctx) {
13
13
  var result = "";
14
14
  if (group && group.pair && group.pair.driverId === session.localId) {
15
15
  result = prompts.DRIVER_CORE + " " + ctx.workerProposal().getSystemPrompt(session);
16
+ if (roles && roles.driver === session) {
17
+ result += " Current configured Worker handles: " + roles.workers.map(function (worker) {
18
+ return "workerId=" + worker.localId + " (" + (worker.title || "Worker") + ")";
19
+ }).join(", ") + ". Call partner_status without workerId to rediscover current Workers; pass workerId for targeted operations.";
20
+ }
16
21
  if (driverOrchestration.isHighTierDriverSession(session, ctx.sm)) result += " " + prompts.DRIVER_DELEGATION;
17
22
  } else if (!group && !ctx.isMate && driverEligibility.isEligibleDriverSession(session, ctx.sm) &&
18
23
  driverOrchestration.isHighTierDriverSession(session, ctx.sm)) {
@@ -36,6 +36,8 @@ function attachProposalRunner(ctx) {
36
36
  } else {
37
37
  followup = "[Split Worker execution failed]\nThe existing Split Worker may still be reusable. Inspect partner_status and send a narrower follow-up with send_to_partner when the Worker and pair still exist; do not replace it merely because its task or transport failed. Retry replacement only when the replacement transaction reports that no new Worker was created and the failure is explicitly safe to retry.\n\n" + (result.error || result.response || "Unknown Split Worker error.");
38
38
  }
39
+ followup += "\n\nWorker handle for this execution: workerId=" + proposal.workerId +
40
+ ". Use this workerId for follow-up operations while it remains configured. Call partner_status without workerId to discover current Worker handles after replacement or reconnect.";
39
41
  await resumeDriver(session, followup);
40
42
  }
41
43
 
@@ -463,7 +463,7 @@ function attachWorkerProposal(ctx) {
463
463
 
464
464
  function getSystemPrompt(session) {
465
465
  if (creationProposal.canMountProposal(session) && !isEligible(session)) {
466
- return "This Driver may configure one additional Split Worker through propose_worker. With two Workers, pass workerId to every targeted partner, queue, interrupt, replacement, close, permission, and status operation. Give each Worker independent file ownership and bounded work. Do not assign simultaneous overlapping file edits unless the work is explicitly separated, such as by distinct paths or worktrees.";
466
+ return "This Driver may configure one additional Split Worker through propose_worker. With two Workers, pass workerId to every targeted partner, queue, interrupt, replacement, close, permission, and targeted status operation. Call partner_status without workerId to discover current Worker handles. Give each Worker independent file ownership and bounded work. Do not assign simultaneous overlapping file edits unless the work is explicitly separated, such as by distinct paths or worktrees.";
467
467
  }
468
468
  if (!isEligible(session) || !driverOrchestration.isHighTierDriverSession(session, sm)) return "";
469
469
  return "For implementation-heavy work, use propose_worker before substantial execution. Work is implementation-heavy when it spans multiple modules, crosses client/server/data boundaries, requires a migration, or contains independent investigation and execution. Do not skip delegation merely because you can implement it yourself; preserve the Driver for decomposition, runtime selection, judgment, review, and integration. The user's explicit request to work directly takes precedence, and work that must remain sequential in this session stays here. Recommend an exact available vendor, model, and effort, and give a concise recommendationRationale explaining why all three fit the task. Clay always records and shows the runtime configuration card. In full-access mode Clay may auto-accept that exact validated recommendation; otherwise it waits for the user's explicit choice. After posting, end the turn. If accepted, Clay creates the exact Driver/Split Worker pair and delegates the proposed task once. If declined, continue in this Driver session. Do not call send_to_partner while unpaired.";
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,
@@ -73,13 +73,11 @@ body.pane-mode #input-area {
73
73
  display: flex;
74
74
  }
75
75
 
76
- /* A configured two-Worker group is a deliberate workspace hierarchy: the
77
- Driver gets the broad working canvas, while both Workers remain visible as
78
- equal-height execution surfaces on the right. Legacy/ad-hoc two-pane
79
- splits keep the ordinary equal columns below. */
76
+ /* Keep the same equal columns when adding a second Worker; only the
77
+ Worker column divides into two equal-height execution surfaces. */
80
78
  #split-host:has(.split-pane-role-worker:nth-of-type(3)) {
81
79
  display: grid;
82
- grid-template-columns: minmax(0, 3fr) minmax(260px, 2fr);
80
+ grid-template-columns: repeat(2, minmax(0, 1fr));
83
81
  grid-template-rows: repeat(2, minmax(0, 1fr));
84
82
  gap: 1px;
85
83
  background: var(--border-subtle);
@@ -63,8 +63,8 @@ function getToolDefs(handlers, options) {
63
63
  },
64
64
  {
65
65
  name: "partner_status",
66
- description: "Bounded capacity and continuity report for your Split Worker, for deciding reuse against replacement: context tokens used and the ratio of its window, current task and activity, vendor/model/effort, history size and idle time, whether it is safe to replace right now, and your own recorded results for earlier Worker generations. Returns no transcript. Prefer this over reading turns when you only need to decide.",
67
- inputSchema: buildShape({ workerId: { type: "number", description: "Optional exact Worker session id; required when multiple Workers are configured." } }),
66
+ description: "Bounded capacity and continuity report for your Split Worker, for deciding reuse against replacement: context tokens used and the ratio of its window, current task and activity, vendor/model/effort, history size and idle time, whether it is safe to replace right now, and your own recorded results for earlier Worker generations. Returns no transcript. Prefer this over reading turns when you only need to decide. Omit workerId to discover all configured Workers and their current numeric handles; pass workerId for one Worker.",
67
+ inputSchema: buildShape({ workerId: { type: "number", description: "Optional exact Worker session id. Omit to list all configured Workers and their current handles." } }),
68
68
  handler: function (args) { return handlers.status(args || {}); },
69
69
  },
70
70
  {
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.2",
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"