clay-server 2.26.0 → 2.27.0-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,482 @@
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var execFileSync = require("child_process").execFileSync;
4
+
5
+ /**
6
+ * Attach filesystem-related message handlers to a project context.
7
+ *
8
+ * ctx fields:
9
+ * cwd, slug, osUsers
10
+ * sm (session manager)
11
+ * send, sendTo
12
+ * safePath, safeAbsPath (functions)
13
+ * getOsUserInfoForWs (function)
14
+ * startFileWatch, stopFileWatch, startDirWatch (from _fileWatch)
15
+ * usersModule, fsAsUser
16
+ * validateEnvString (function)
17
+ * opts (for onGetProjectEnv, onSetProjectEnv, onGetSharedEnv, onSetSharedEnv callbacks)
18
+ * IGNORED_DIRS, BINARY_EXTS, IMAGE_EXTS, FS_MAX_SIZE (constants)
19
+ */
20
+ function attachFilesystem(ctx) {
21
+ var cwd = ctx.cwd;
22
+ var slug = ctx.slug;
23
+ var osUsers = ctx.osUsers;
24
+ var sm = ctx.sm;
25
+ var send = ctx.send;
26
+ var sendTo = ctx.sendTo;
27
+ var safePath = ctx.safePath;
28
+ var safeAbsPath = ctx.safeAbsPath;
29
+ var getOsUserInfoForWs = ctx.getOsUserInfoForWs;
30
+ var startFileWatch = ctx.startFileWatch;
31
+ var stopFileWatch = ctx.stopFileWatch;
32
+ var startDirWatch = ctx.startDirWatch;
33
+ var usersModule = ctx.usersModule;
34
+ var fsAsUser = ctx.fsAsUser;
35
+ var validateEnvString = ctx.validateEnvString;
36
+ var opts = ctx.opts;
37
+ var IGNORED_DIRS = ctx.IGNORED_DIRS;
38
+ var BINARY_EXTS = ctx.BINARY_EXTS;
39
+ var IMAGE_EXTS = ctx.IMAGE_EXTS;
40
+ var FS_MAX_SIZE = ctx.FS_MAX_SIZE;
41
+
42
+ function handleFilesystemMessage(ws, msg) {
43
+ // --- File browser permission gate ---
44
+ if (msg.type === "fs_list" || msg.type === "fs_read" || msg.type === "fs_write" || msg.type === "fs_delete" || msg.type === "fs_rename" || msg.type === "fs_mkdir" || msg.type === "fs_upload") {
45
+ if (ws._clayUser) {
46
+ var fbPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
47
+ if (!fbPerms.fileBrowser) {
48
+ sendTo(ws, { type: msg.type + "_result", error: "File browser access is not permitted" });
49
+ return true;
50
+ }
51
+ }
52
+ }
53
+
54
+ // --- fs_list ---
55
+ if (msg.type === "fs_list") {
56
+ var fsDir = safePath(cwd, msg.path || ".");
57
+ // In OS user mode, fall back to absolute path resolution (ACL enforces access)
58
+ if (!fsDir && getOsUserInfoForWs(ws)) {
59
+ fsDir = safeAbsPath(msg.path);
60
+ }
61
+ if (!fsDir) {
62
+ sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: "Access denied" });
63
+ return true;
64
+ }
65
+ try {
66
+ var fsListUserInfo = getOsUserInfoForWs(ws);
67
+ var entries = [];
68
+ if (fsListUserInfo) {
69
+ // Run as target OS user to respect Linux file permissions
70
+ var rawEntries = fsAsUser("list", { dir: fsDir }, fsListUserInfo);
71
+ for (var fi = 0; fi < rawEntries.length; fi++) {
72
+ var re = rawEntries[fi];
73
+ if (re.isDir && IGNORED_DIRS.has(re.name)) continue;
74
+ entries.push({
75
+ name: re.name,
76
+ type: re.isDir ? "dir" : "file",
77
+ path: path.relative(cwd, path.join(fsDir, re.name)).split(path.sep).join("/"),
78
+ });
79
+ }
80
+ } else {
81
+ var items = fs.readdirSync(fsDir, { withFileTypes: true });
82
+ for (var fi = 0; fi < items.length; fi++) {
83
+ var item = items[fi];
84
+ if (item.isDirectory() && IGNORED_DIRS.has(item.name)) continue;
85
+ entries.push({
86
+ name: item.name,
87
+ type: item.isDirectory() ? "dir" : "file",
88
+ path: path.relative(cwd, path.join(fsDir, item.name)).split(path.sep).join("/"),
89
+ });
90
+ }
91
+ }
92
+ sendTo(ws, { type: "fs_list_result", path: msg.path || ".", entries: entries });
93
+ // Auto-watch the directory for changes
94
+ startDirWatch(msg.path || ".");
95
+ } catch (e) {
96
+ sendTo(ws, { type: "fs_list_result", path: msg.path, entries: [], error: e.message });
97
+ }
98
+ return true;
99
+ }
100
+
101
+ // --- fs_read ---
102
+ if (msg.type === "fs_read") {
103
+ var fsFile = safePath(cwd, msg.path);
104
+ if (!fsFile && getOsUserInfoForWs(ws)) {
105
+ fsFile = safeAbsPath(msg.path);
106
+ }
107
+ if (!fsFile) {
108
+ sendTo(ws, { type: "fs_read_result", path: msg.path, error: "Access denied" });
109
+ return true;
110
+ }
111
+ try {
112
+ var fsReadUserInfo = getOsUserInfoForWs(ws);
113
+ var ext = path.extname(fsFile).toLowerCase();
114
+ if (fsReadUserInfo) {
115
+ // Run stat and read as target OS user
116
+ var statResult = fsAsUser("stat", { file: fsFile }, fsReadUserInfo);
117
+ if (statResult.size > FS_MAX_SIZE) {
118
+ sendTo(ws, { type: "fs_read_result", path: msg.path, binary: true, size: statResult.size, error: "File too large (" + (statResult.size / 1024 / 1024).toFixed(1) + " MB)" });
119
+ return true;
120
+ }
121
+ if (BINARY_EXTS.has(ext)) {
122
+ var result = { type: "fs_read_result", path: msg.path, binary: true, size: statResult.size };
123
+ if (IMAGE_EXTS.has(ext)) result.imageUrl = "api/file?path=" + encodeURIComponent(msg.path);
124
+ sendTo(ws, result);
125
+ return true;
126
+ }
127
+ var readResult = fsAsUser("read", { file: fsFile, readContent: true }, fsReadUserInfo);
128
+ sendTo(ws, { type: "fs_read_result", path: msg.path, content: readResult.content, size: statResult.size });
129
+ } else {
130
+ var stat = fs.statSync(fsFile);
131
+ if (stat.size > FS_MAX_SIZE) {
132
+ sendTo(ws, { type: "fs_read_result", path: msg.path, binary: true, size: stat.size, error: "File too large (" + (stat.size / 1024 / 1024).toFixed(1) + " MB)" });
133
+ return true;
134
+ }
135
+ if (BINARY_EXTS.has(ext)) {
136
+ var result = { type: "fs_read_result", path: msg.path, binary: true, size: stat.size };
137
+ if (IMAGE_EXTS.has(ext)) result.imageUrl = "api/file?path=" + encodeURIComponent(msg.path);
138
+ sendTo(ws, result);
139
+ return true;
140
+ }
141
+ var content = fs.readFileSync(fsFile, "utf8");
142
+ sendTo(ws, { type: "fs_read_result", path: msg.path, content: content, size: stat.size });
143
+ }
144
+ } catch (e) {
145
+ sendTo(ws, { type: "fs_read_result", path: msg.path, error: e.message });
146
+ }
147
+ return true;
148
+ }
149
+
150
+ // --- fs_write ---
151
+ if (msg.type === "fs_write") {
152
+ var fsWriteFile = safePath(cwd, msg.path);
153
+ if (!fsWriteFile && getOsUserInfoForWs(ws)) {
154
+ fsWriteFile = safeAbsPath(msg.path);
155
+ }
156
+ if (!fsWriteFile) {
157
+ sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: "Access denied" });
158
+ return true;
159
+ }
160
+ try {
161
+ var fsWriteUserInfo = getOsUserInfoForWs(ws);
162
+ if (fsWriteUserInfo) {
163
+ fsAsUser("write", { file: fsWriteFile, content: msg.content || "" }, fsWriteUserInfo);
164
+ } else {
165
+ fs.writeFileSync(fsWriteFile, msg.content || "", "utf8");
166
+ }
167
+ sendTo(ws, { type: "fs_write_result", path: msg.path, ok: true });
168
+ } catch (e) {
169
+ sendTo(ws, { type: "fs_write_result", path: msg.path, ok: false, error: e.message });
170
+ }
171
+ return true;
172
+ }
173
+
174
+ // --- Project settings permission gate ---
175
+ if (msg.type === "get_project_env" || msg.type === "set_project_env" ||
176
+ msg.type === "read_global_claude_md" || msg.type === "write_global_claude_md" ||
177
+ msg.type === "get_shared_env" || msg.type === "set_shared_env" ||
178
+ msg.type === "transfer_project_owner") {
179
+ if (ws._clayUser) {
180
+ var psPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
181
+ if (!psPerms.projectSettings) {
182
+ sendTo(ws, { type: "error", text: "Project settings access is not permitted" });
183
+ return true;
184
+ }
185
+ }
186
+ }
187
+
188
+ // --- Project environment variables ---
189
+ if (msg.type === "get_project_env") {
190
+ var envrc = "";
191
+ var hasEnvrc = false;
192
+ if (typeof opts.onGetProjectEnv === "function") {
193
+ var envResult = opts.onGetProjectEnv(msg.slug);
194
+ envrc = envResult.envrc || "";
195
+ }
196
+ try {
197
+ var envrcPath = path.join(cwd, ".envrc");
198
+ hasEnvrc = fs.existsSync(envrcPath);
199
+ } catch (e) {}
200
+ sendTo(ws, { type: "project_env_result", slug: msg.slug, envrc: envrc, hasEnvrc: hasEnvrc });
201
+ return true;
202
+ }
203
+
204
+ if (msg.type === "set_project_env") {
205
+ if (typeof opts.onSetProjectEnv === "function") {
206
+ var envError = validateEnvString(msg.envrc || "");
207
+ if (envError) {
208
+ sendTo(ws, { type: "set_project_env_result", ok: false, slug: msg.slug, error: envError });
209
+ return true;
210
+ }
211
+ var setResult = opts.onSetProjectEnv(msg.slug, msg.envrc || "");
212
+ sendTo(ws, { type: "set_project_env_result", ok: setResult.ok, slug: msg.slug, error: setResult.error });
213
+ } else {
214
+ sendTo(ws, { type: "set_project_env_result", ok: false, error: "Not supported" });
215
+ }
216
+ return true;
217
+ }
218
+
219
+ // --- Global CLAUDE.md ---
220
+ if (msg.type === "read_global_claude_md") {
221
+ var globalMdPath = path.join(require("./config").REAL_HOME, ".claude", "CLAUDE.md");
222
+ try {
223
+ var globalMdContent = fs.readFileSync(globalMdPath, "utf8");
224
+ sendTo(ws, { type: "global_claude_md_result", content: globalMdContent });
225
+ } catch (e) {
226
+ sendTo(ws, { type: "global_claude_md_result", error: e.message });
227
+ }
228
+ return true;
229
+ }
230
+
231
+ if (msg.type === "write_global_claude_md") {
232
+ var globalMdDir = path.join(require("./config").REAL_HOME, ".claude");
233
+ var globalMdWritePath = path.join(globalMdDir, "CLAUDE.md");
234
+ try {
235
+ if (!fs.existsSync(globalMdDir)) {
236
+ fs.mkdirSync(globalMdDir, { recursive: true });
237
+ }
238
+ fs.writeFileSync(globalMdWritePath, msg.content || "", "utf8");
239
+ sendTo(ws, { type: "write_global_claude_md_result", ok: true });
240
+ } catch (e) {
241
+ sendTo(ws, { type: "write_global_claude_md_result", ok: false, error: e.message });
242
+ }
243
+ return true;
244
+ }
245
+
246
+ // --- Shared environment variables ---
247
+ if (msg.type === "get_shared_env") {
248
+ var sharedEnvrc = "";
249
+ if (typeof opts.onGetSharedEnv === "function") {
250
+ var sharedResult = opts.onGetSharedEnv();
251
+ sharedEnvrc = sharedResult.envrc || "";
252
+ }
253
+ sendTo(ws, { type: "shared_env_result", envrc: sharedEnvrc });
254
+ return true;
255
+ }
256
+
257
+ if (msg.type === "set_shared_env") {
258
+ if (typeof opts.onSetSharedEnv === "function") {
259
+ var sharedEnvError = validateEnvString(msg.envrc || "");
260
+ if (sharedEnvError) {
261
+ sendTo(ws, { type: "set_shared_env_result", ok: false, error: sharedEnvError });
262
+ return true;
263
+ }
264
+ var sharedSetResult = opts.onSetSharedEnv(msg.envrc || "");
265
+ sendTo(ws, { type: "set_shared_env_result", ok: sharedSetResult.ok, error: sharedSetResult.error });
266
+ } else {
267
+ sendTo(ws, { type: "set_shared_env_result", ok: false, error: "Not supported" });
268
+ }
269
+ return true;
270
+ }
271
+
272
+ // --- File watcher ---
273
+ if (msg.type === "fs_watch") {
274
+ if (msg.path) startFileWatch(msg.path);
275
+ return true;
276
+ }
277
+
278
+ if (msg.type === "fs_unwatch") {
279
+ stopFileWatch();
280
+ return true;
281
+ }
282
+
283
+ // --- File edit history ---
284
+ if (msg.type === "fs_file_history") {
285
+ var histPath = msg.path;
286
+ if (!histPath) {
287
+ sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: [] });
288
+ return true;
289
+ }
290
+ var absHistPath = path.resolve(cwd, histPath);
291
+ var entries = [];
292
+
293
+ // Collect session edits
294
+ sm.sessions.forEach(function (session) {
295
+ var sessionLocalId = session.localId;
296
+ var sessionTitle = session.title || "Untitled";
297
+ var histLen = session.history.length || 1;
298
+
299
+ for (var hi = 0; hi < session.history.length; hi++) {
300
+ var entry = session.history[hi];
301
+ if (entry.type !== "tool_executing") continue;
302
+ if (entry.name !== "Edit" && entry.name !== "Write") continue;
303
+ if (!entry.input || !entry.input.file_path) continue;
304
+ if (entry.input.file_path !== absHistPath) continue;
305
+
306
+ // Find parent assistant UUID + message snippet by scanning backwards
307
+ var assistantUuid = null;
308
+ var uuidIndex = -1;
309
+ for (var hj = hi - 1; hj >= 0; hj--) {
310
+ if (session.history[hj].type === "message_uuid" && session.history[hj].messageType === "assistant") {
311
+ assistantUuid = session.history[hj].uuid;
312
+ uuidIndex = hj;
313
+ break;
314
+ }
315
+ }
316
+
317
+ // Find user prompt by scanning backwards from the assistant uuid
318
+ var messageSnippet = "";
319
+ var searchFrom = uuidIndex >= 0 ? uuidIndex : hi;
320
+ for (var hk = searchFrom - 1; hk >= 0; hk--) {
321
+ if (session.history[hk].type === "user_message" && session.history[hk].text) {
322
+ messageSnippet = session.history[hk].text.trim().substring(0, 100);
323
+ break;
324
+ }
325
+ }
326
+
327
+ // Collect Claude's explanation: scan backwards from tool_executing
328
+ // to find the nearest delta text block (skipping tool_start).
329
+ // If no delta found immediately before this tool, scan past
330
+ // intervening tool blocks to find the last delta text within
331
+ // the same assistant turn.
332
+ var assistantSnippet = "";
333
+ var deltaChunks = [];
334
+ for (var hd = hi - 1; hd >= 0; hd--) {
335
+ var hEntry = session.history[hd];
336
+ if (hEntry.type === "tool_start") continue;
337
+ if (hEntry.type === "delta" && hEntry.text) {
338
+ deltaChunks.unshift(hEntry.text);
339
+ } else {
340
+ break;
341
+ }
342
+ }
343
+ if (deltaChunks.length === 0) {
344
+ // No delta immediately before; scan past tool blocks
345
+ // to find the nearest preceding delta in the same turn
346
+ for (var hd2 = hi - 1; hd2 >= 0; hd2--) {
347
+ var hEntry2 = session.history[hd2];
348
+ if (hEntry2.type === "tool_start" || hEntry2.type === "tool_executing" || hEntry2.type === "tool_result") continue;
349
+ if (hEntry2.type === "delta" && hEntry2.text) {
350
+ // Found a delta before an earlier tool in the same turn.
351
+ // Collect this contiguous block of deltas.
352
+ for (var hd3 = hd2; hd3 >= 0; hd3--) {
353
+ var hEntry3 = session.history[hd3];
354
+ if (hEntry3.type === "tool_start") continue;
355
+ if (hEntry3.type === "delta" && hEntry3.text) {
356
+ deltaChunks.unshift(hEntry3.text);
357
+ } else {
358
+ break;
359
+ }
360
+ }
361
+ break;
362
+ } else {
363
+ // Hit message_uuid, user_message, etc. Stop.
364
+ break;
365
+ }
366
+ }
367
+ }
368
+ assistantSnippet = deltaChunks.join("").trim().substring(0, 150);
369
+
370
+ // Approximate timestamp: interpolate between session creation and last activity
371
+ var tStart = session.createdAt || 0;
372
+ var tEnd = session.lastActivity || tStart;
373
+ var ts = tStart + Math.floor((hi / histLen) * (tEnd - tStart));
374
+
375
+ var editRecord = {
376
+ source: "session",
377
+ timestamp: ts,
378
+ sessionLocalId: sessionLocalId,
379
+ sessionTitle: sessionTitle,
380
+ assistantUuid: assistantUuid,
381
+ toolId: entry.id,
382
+ messageSnippet: messageSnippet,
383
+ assistantSnippet: assistantSnippet,
384
+ toolName: entry.name,
385
+ };
386
+
387
+ if (entry.name === "Edit") {
388
+ editRecord.old_string = entry.input.old_string || "";
389
+ editRecord.new_string = entry.input.new_string || "";
390
+ } else {
391
+ editRecord.isFullWrite = true;
392
+ }
393
+
394
+ entries.push(editRecord);
395
+ }
396
+ });
397
+
398
+ // Collect git commits
399
+ try {
400
+ var gitLog = execFileSync(
401
+ "git", ["log", "--format=%H|%at|%an|%s", "--follow", "--", histPath],
402
+ { cwd: cwd, encoding: "utf8", timeout: 5000 }
403
+ );
404
+ var gitLines = gitLog.trim().split("\n");
405
+ for (var gi = 0; gi < gitLines.length; gi++) {
406
+ if (!gitLines[gi]) continue;
407
+ var parts = gitLines[gi].split("|");
408
+ if (parts.length < 4) continue;
409
+ entries.push({
410
+ source: "git",
411
+ hash: parts[0],
412
+ timestamp: parseInt(parts[1], 10) * 1000,
413
+ author: parts[2],
414
+ message: parts.slice(3).join("|"),
415
+ });
416
+ }
417
+ } catch (e) {
418
+ // Not a git repo or file not tracked, that is fine
419
+ }
420
+
421
+ // Sort by timestamp descending (newest first)
422
+ entries.sort(function (a, b) { return b.timestamp - a.timestamp; });
423
+
424
+ sendTo(ws, { type: "fs_file_history_result", path: histPath, entries: entries });
425
+ return true;
426
+ }
427
+
428
+ // --- Git diff for file history ---
429
+ if (msg.type === "fs_git_diff") {
430
+ var diffPath = msg.path;
431
+ var hash = msg.hash;
432
+ var hash2 = msg.hash2 || null;
433
+ if (!diffPath || !hash) {
434
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, path: diffPath, diff: "", error: "Missing params" });
435
+ return true;
436
+ }
437
+ try {
438
+ var diff;
439
+ if (hash2) {
440
+ diff = execFileSync("git", ["diff", hash, hash2, "--", diffPath],
441
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
442
+ } else {
443
+ diff = execFileSync("git", ["show", hash, "--format=", "--", diffPath],
444
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
445
+ }
446
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: diff || "" });
447
+ } catch (e) {
448
+ sendTo(ws, { type: "fs_git_diff_result", hash: hash, hash2: hash2, path: diffPath, diff: "", error: e.message });
449
+ }
450
+ return true;
451
+ }
452
+
453
+ // --- File content at a git commit ---
454
+ if (msg.type === "fs_file_at") {
455
+ var atPath = msg.path;
456
+ var atHash = msg.hash;
457
+ if (!atPath || !atHash) {
458
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: "Missing params" });
459
+ return true;
460
+ }
461
+ try {
462
+ // Convert to repo-relative path (git show requires hash:relative/path)
463
+ var atAbsPath = path.resolve(cwd, atPath);
464
+ var atRelPath = path.relative(cwd, atAbsPath);
465
+ var content = execFileSync("git", ["show", atHash + ":" + atRelPath],
466
+ { cwd: cwd, encoding: "utf8", timeout: 5000 });
467
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: content });
468
+ } catch (e) {
469
+ sendTo(ws, { type: "fs_file_at_result", hash: atHash, path: atPath, content: "", error: e.message });
470
+ }
471
+ return true;
472
+ }
473
+
474
+ return false;
475
+ }
476
+
477
+ return {
478
+ handleFilesystemMessage: handleFilesystemMessage,
479
+ };
480
+ }
481
+
482
+ module.exports = { attachFilesystem: attachFilesystem };