clay-server 2.10.0 → 2.11.0-beta.10

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.
package/lib/dm.js ADDED
@@ -0,0 +1,135 @@
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var config = require("./config");
4
+
5
+ var DM_DIR = path.join(config.CONFIG_DIR, "dm");
6
+
7
+ // Ensure dm directory exists
8
+ function ensureDmDir() {
9
+ fs.mkdirSync(DM_DIR, { recursive: true });
10
+ config.chmodSafe(DM_DIR, 0o700);
11
+ }
12
+
13
+ // Generate deterministic DM key from two user IDs (sorted, order-independent)
14
+ function dmKey(userId1, userId2) {
15
+ return [userId1, userId2].sort().join(":");
16
+ }
17
+
18
+ // File path for a DM conversation
19
+ function dmFilePath(key) {
20
+ // Replace : with _ for safe filename
21
+ return path.join(DM_DIR, key.replace(/:/g, "_") + ".jsonl");
22
+ }
23
+
24
+ // Load DM history from JSONL file
25
+ function loadHistory(key) {
26
+ var filePath = dmFilePath(key);
27
+ if (!fs.existsSync(filePath)) return [];
28
+ try {
29
+ var content = fs.readFileSync(filePath, "utf8").trim();
30
+ if (!content) return [];
31
+ var lines = content.split("\n");
32
+ var messages = [];
33
+ for (var i = 0; i < lines.length; i++) {
34
+ if (!lines[i].trim()) continue;
35
+ try {
36
+ messages.push(JSON.parse(lines[i]));
37
+ } catch (e) {
38
+ // skip malformed lines
39
+ }
40
+ }
41
+ return messages;
42
+ } catch (e) {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ // Append a message to DM JSONL file
48
+ function appendMessage(key, message) {
49
+ ensureDmDir();
50
+ var filePath = dmFilePath(key);
51
+ var line = JSON.stringify(message) + "\n";
52
+ fs.appendFileSync(filePath, line);
53
+ }
54
+
55
+ // Open a DM conversation (find or create)
56
+ function openDm(userId1, userId2) {
57
+ var key = dmKey(userId1, userId2);
58
+ var history = loadHistory(key);
59
+ return { dmKey: key, messages: history };
60
+ }
61
+
62
+ // Send a DM message
63
+ function sendMessage(key, fromUserId, text) {
64
+ var message = {
65
+ type: "dm_message",
66
+ ts: Date.now(),
67
+ from: fromUserId,
68
+ text: text,
69
+ };
70
+ appendMessage(key, message);
71
+ return message;
72
+ }
73
+
74
+ // Get list of all DM conversations for a user
75
+ // Returns: [{ dmKey, otherUserId, lastMessage, lastTs }]
76
+ function getDmList(userId) {
77
+ ensureDmDir();
78
+ var files;
79
+ try {
80
+ files = fs.readdirSync(DM_DIR).filter(function (f) {
81
+ return f.endsWith(".jsonl");
82
+ });
83
+ } catch (e) {
84
+ return [];
85
+ }
86
+
87
+ var dms = [];
88
+ for (var i = 0; i < files.length; i++) {
89
+ // Reconstruct dmKey from filename (replace _ back to :)
90
+ var key = files[i].replace(".jsonl", "").replace(/_/g, ":");
91
+ var parts = key.split(":");
92
+ if (parts.length !== 2) continue;
93
+
94
+ // Check if this user is a participant
95
+ var idx = parts.indexOf(userId);
96
+ if (idx === -1) continue;
97
+
98
+ var otherUserId = parts[idx === 0 ? 1 : 0];
99
+
100
+ // Get last message
101
+ var messages = loadHistory(key);
102
+ var lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
103
+
104
+ dms.push({
105
+ dmKey: key,
106
+ otherUserId: otherUserId,
107
+ lastMessage: lastMessage ? lastMessage.text : null,
108
+ lastTs: lastMessage ? lastMessage.ts : 0,
109
+ messageCount: messages.length,
110
+ });
111
+ }
112
+
113
+ // Sort by most recent activity
114
+ dms.sort(function (a, b) {
115
+ return b.lastTs - a.lastTs;
116
+ });
117
+
118
+ return dms;
119
+ }
120
+
121
+ // Extension point: check if a user is a mate (AI persona)
122
+ // Returns false for now - will be implemented when Mates feature is added
123
+ function isMate(userId) {
124
+ return false;
125
+ }
126
+
127
+ module.exports = {
128
+ dmKey: dmKey,
129
+ openDm: openDm,
130
+ sendMessage: sendMessage,
131
+ getDmList: getDmList,
132
+ loadHistory: loadHistory,
133
+ isMate: isMate,
134
+ DM_DIR: DM_DIR,
135
+ };
@@ -0,0 +1,301 @@
1
+ // os-users.js — Shared utility for resolving Linux OS user information.
2
+ // Used by sdk-bridge.js (worker spawning), terminal-manager.js, and project.js (file ops).
3
+
4
+ var fs = require("fs");
5
+ var { execSync } = require("child_process");
6
+
7
+ /**
8
+ * Resolve Linux user info from username via getent passwd.
9
+ * Returns { uid, gid, home, user, shell } or throws on failure.
10
+ */
11
+ function resolveOsUserInfo(username) {
12
+ var output = execSync("getent passwd " + username, { encoding: "utf8", timeout: 5000 }).trim();
13
+ // getent passwd format: username:x:uid:gid:gecos:home:shell
14
+ var parts = output.split(":");
15
+ if (parts.length < 7) throw new Error("Unexpected getent output for user " + username);
16
+ return {
17
+ uid: parseInt(parts[2], 10),
18
+ gid: parseInt(parts[3], 10),
19
+ home: parts[5],
20
+ user: parts[0],
21
+ shell: parts[6] || "/bin/bash",
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Run a file system operation as a specific OS user via a helper subprocess.
27
+ * op: "list", "read", "write", "stat"
28
+ * args: operation-specific arguments
29
+ * osUserInfo: { uid, gid } from resolveOsUserInfo
30
+ * Returns the result object or throws.
31
+ */
32
+ function fsAsUser(op, args, osUserInfo) {
33
+ // Build a small inline Node script to run as the target user
34
+ var script;
35
+ if (op === "list") {
36
+ script = [
37
+ "var fs = require('fs');",
38
+ "var p = require('path');",
39
+ "var dir = " + JSON.stringify(args.dir) + ";",
40
+ "var entries = fs.readdirSync(dir, { withFileTypes: true });",
41
+ "var result = [];",
42
+ "for (var i = 0; i < entries.length; i++) {",
43
+ " var e = entries[i];",
44
+ " var stat;",
45
+ " try { stat = fs.statSync(p.join(dir, e.name)); } catch(err) { continue; }",
46
+ " result.push({ name: e.name, isDir: e.isDirectory(), size: stat.size, mtime: stat.mtimeMs });",
47
+ "}",
48
+ "process.stdout.write(JSON.stringify(result));",
49
+ ].join("\n");
50
+ } else if (op === "read") {
51
+ script = [
52
+ "var fs = require('fs');",
53
+ "var f = " + JSON.stringify(args.file) + ";",
54
+ "var stat = fs.statSync(f);",
55
+ "var result = { size: stat.size };",
56
+ "if (" + JSON.stringify(!!args.readContent) + ") {",
57
+ " result.content = fs.readFileSync(f, 'utf8');",
58
+ "}",
59
+ "process.stdout.write(JSON.stringify(result));",
60
+ ].join("\n");
61
+ } else if (op === "stat") {
62
+ script = [
63
+ "var fs = require('fs');",
64
+ "var f = " + JSON.stringify(args.file) + ";",
65
+ "var stat = fs.statSync(f);",
66
+ "process.stdout.write(JSON.stringify({ size: stat.size, isDir: stat.isDirectory(), mtime: stat.mtimeMs }));",
67
+ ].join("\n");
68
+ } else if (op === "read_binary") {
69
+ // Read file as base64 for binary content (images, etc.)
70
+ script = [
71
+ "var fs = require('fs');",
72
+ "var f = " + JSON.stringify(args.file) + ";",
73
+ "var buf = fs.readFileSync(f);",
74
+ "process.stdout.write(buf.toString('base64'));",
75
+ ].join("\n");
76
+ var binOutput = execSync(process.execPath + " -e " + JSON.stringify(script), {
77
+ encoding: "utf8",
78
+ timeout: 10000,
79
+ uid: osUserInfo.uid,
80
+ gid: osUserInfo.gid,
81
+ maxBuffer: 50 * 1024 * 1024,
82
+ });
83
+ return { buffer: Buffer.from(binOutput.trim(), "base64") };
84
+ } else if (op === "write") {
85
+ script = [
86
+ "var fs = require('fs');",
87
+ "var f = " + JSON.stringify(args.file) + ";",
88
+ "var content = " + JSON.stringify(args.content || "") + ";",
89
+ "fs.writeFileSync(f, content, 'utf8');",
90
+ "process.stdout.write(JSON.stringify({ ok: true }));",
91
+ ].join("\n");
92
+ } else {
93
+ throw new Error("Unknown fsAsUser operation: " + op);
94
+ }
95
+
96
+ var output = execSync(process.execPath + " -e " + JSON.stringify(script), {
97
+ encoding: "utf8",
98
+ timeout: 10000,
99
+ uid: osUserInfo.uid,
100
+ gid: osUserInfo.gid,
101
+ });
102
+
103
+ return JSON.parse(output.trim());
104
+ }
105
+
106
+ /**
107
+ * Grant a Linux user ACL access (rwX) to a project directory.
108
+ * Uses setfacl to add recursive + default ACL entries.
109
+ */
110
+ function grantProjectAccess(projectPath, linuxUser) {
111
+ try {
112
+ // Recursive ACL for existing files
113
+ execSync("setfacl -R -m u:" + linuxUser + ":rwX " + JSON.stringify(projectPath), {
114
+ encoding: "utf8",
115
+ timeout: 30000,
116
+ });
117
+ // Default ACL so new files also inherit access
118
+ execSync("setfacl -R -d -m u:" + linuxUser + ":rwX " + JSON.stringify(projectPath), {
119
+ encoding: "utf8",
120
+ timeout: 30000,
121
+ });
122
+ console.log("[os-users] Granted ACL access for " + linuxUser + " on " + projectPath);
123
+ } catch (e) {
124
+ console.error("[os-users] Failed to grant ACL access for " + linuxUser + " on " + projectPath + ": " + (e.message || e));
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Revoke a Linux user's ACL access from a project directory.
130
+ */
131
+ function revokeProjectAccess(projectPath, linuxUser) {
132
+ try {
133
+ execSync("setfacl -R -x u:" + linuxUser + " " + JSON.stringify(projectPath), {
134
+ encoding: "utf8",
135
+ timeout: 30000,
136
+ });
137
+ execSync("setfacl -R -d -x u:" + linuxUser + " " + JSON.stringify(projectPath), {
138
+ encoding: "utf8",
139
+ timeout: 30000,
140
+ });
141
+ console.log("[os-users] Revoked ACL access for " + linuxUser + " on " + projectPath);
142
+ } catch (e) {
143
+ console.error("[os-users] Failed to revoke ACL access for " + linuxUser + " on " + projectPath + ": " + (e.message || e));
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Sanitize a Clay username into a valid Linux username.
149
+ * Prefixes with "clay-", lowercases, replaces invalid chars with hyphens.
150
+ * Linux usernames: start with [a-z_], then [a-z0-9_-], max 32 chars.
151
+ */
152
+ function toLinuxUsername(clayUsername) {
153
+ var sanitized = clayUsername.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
154
+ // Remove leading hyphens/digits after prefix
155
+ sanitized = sanitized.replace(/^[-0-9]+/, "");
156
+ if (!sanitized) sanitized = "user";
157
+ var name = "clay-" + sanitized;
158
+ // Truncate to 32 chars (Linux limit)
159
+ if (name.length > 32) name = name.substring(0, 32);
160
+ // Remove trailing hyphens
161
+ name = name.replace(/-+$/, "");
162
+ return name;
163
+ }
164
+
165
+ /**
166
+ * Check if a Linux user already exists.
167
+ */
168
+ function linuxUserExists(username) {
169
+ try {
170
+ execSync("id " + username, { encoding: "utf8", timeout: 5000, stdio: "pipe" });
171
+ return true;
172
+ } catch (e) {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Provision a Linux user account for a Clay user.
179
+ * Creates the account via useradd with a home directory.
180
+ * Returns { ok: true, linuxUser: "clay-xxx" } or { error: "..." }.
181
+ */
182
+ function provisionLinuxUser(clayUsername) {
183
+ var linuxName = toLinuxUsername(clayUsername);
184
+
185
+ // Handle name collisions by appending a number
186
+ if (linuxUserExists(linuxName)) {
187
+ // Check if this is a clay-managed user (reuse it)
188
+ // Otherwise find an available name
189
+ console.log("[os-users] Linux user " + linuxName + " already exists, reusing.");
190
+ return { ok: true, linuxUser: linuxName };
191
+ }
192
+
193
+ try {
194
+ execSync("useradd -m -s /bin/bash " + linuxName, {
195
+ encoding: "utf8",
196
+ timeout: 15000,
197
+ stdio: "pipe",
198
+ });
199
+ console.log("[os-users] Provisioned Linux user: " + linuxName + " (Clay user: " + clayUsername + ")");
200
+ return { ok: true, linuxUser: linuxName };
201
+ } catch (e) {
202
+ var msg = (e.stderr || e.message || "").trim();
203
+ console.error("[os-users] Failed to provision Linux user " + linuxName + ": " + msg);
204
+ return { error: "Failed to create Linux user " + linuxName + ": " + msg };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Provision Linux accounts for all Clay users that don't have one yet.
210
+ * usersModule: the users.js module (getAllUsers, updateLinuxUser, etc.)
211
+ * Returns { provisioned: [...], skipped: [...], errors: [...] }.
212
+ */
213
+ function provisionAllUsers(usersModule) {
214
+ var users = usersModule.getAllUsers();
215
+ var result = { provisioned: [], skipped: [], errors: [] };
216
+
217
+ for (var i = 0; i < users.length; i++) {
218
+ var user = users[i];
219
+ if (user.linuxUser) {
220
+ // Already mapped, verify the Linux user still exists
221
+ if (linuxUserExists(user.linuxUser)) {
222
+ result.skipped.push({ id: user.id, username: user.username, linuxUser: user.linuxUser });
223
+ continue;
224
+ }
225
+ // Linux user was deleted externally, re-provision
226
+ console.log("[os-users] Linux user " + user.linuxUser + " no longer exists, re-provisioning for " + user.username);
227
+ }
228
+
229
+ var provision = provisionLinuxUser(user.username);
230
+ if (provision.ok) {
231
+ // Update the Clay user record with the Linux username
232
+ var data = usersModule.loadUsers();
233
+ for (var j = 0; j < data.users.length; j++) {
234
+ if (data.users[j].id === user.id) {
235
+ data.users[j].linuxUser = provision.linuxUser;
236
+ usersModule.saveUsers(data);
237
+ break;
238
+ }
239
+ }
240
+ result.provisioned.push({ id: user.id, username: user.username, linuxUser: provision.linuxUser });
241
+ } else {
242
+ result.errors.push({ id: user.id, username: user.username, error: provision.error });
243
+ }
244
+ }
245
+
246
+ return result;
247
+ }
248
+
249
+ /**
250
+ * Grant ACL access on a project directory to ALL Clay users with linuxUser mappings.
251
+ * Used when a project becomes public.
252
+ * usersModule: the users.js module (getAllUsers, etc.)
253
+ */
254
+ function grantAllUsersAccess(projectPath, usersModule) {
255
+ var allUsers = usersModule.getAllUsers();
256
+ for (var i = 0; i < allUsers.length; i++) {
257
+ if (allUsers[i].linuxUser) {
258
+ grantProjectAccess(projectPath, allUsers[i].linuxUser);
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Deactivate (lock) a Linux user account.
265
+ * The account and home directory are preserved, but login is disabled.
266
+ */
267
+ function deactivateLinuxUser(linuxUsername) {
268
+ try {
269
+ execSync("usermod -L " + linuxUsername, { encoding: "utf8", timeout: 5000, stdio: "pipe" });
270
+ console.log("[os-users] Deactivated Linux user: " + linuxUsername);
271
+ return { ok: true };
272
+ } catch (e) {
273
+ var msg = (e.stderr || e.message || "").trim();
274
+ console.error("[os-users] Failed to deactivate Linux user " + linuxUsername + ": " + msg);
275
+ return { error: "Failed to deactivate Linux user " + linuxUsername + ": " + msg };
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Ensure the shared projects directory exists.
281
+ */
282
+ function ensureProjectsDir() {
283
+ var dir = "/var/clay/projects";
284
+ if (!fs.existsSync(dir)) {
285
+ fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
286
+ }
287
+ }
288
+
289
+ module.exports = {
290
+ resolveOsUserInfo: resolveOsUserInfo,
291
+ fsAsUser: fsAsUser,
292
+ grantProjectAccess: grantProjectAccess,
293
+ revokeProjectAccess: revokeProjectAccess,
294
+ toLinuxUsername: toLinuxUsername,
295
+ linuxUserExists: linuxUserExists,
296
+ provisionLinuxUser: provisionLinuxUser,
297
+ provisionAllUsers: provisionAllUsers,
298
+ grantAllUsersAccess: grantAllUsersAccess,
299
+ deactivateLinuxUser: deactivateLinuxUser,
300
+ ensureProjectsDir: ensureProjectsDir,
301
+ };
package/lib/pages.js CHANGED
@@ -954,6 +954,7 @@ function multiUserLoginPageHtml() {
954
954
  'body:JSON.stringify({username:usernameEl.value,pin:pinEl.value})})' +
955
955
  '.then(function(r){return r.json()})' +
956
956
  '.then(function(d){' +
957
+ 'if(d.ok&&d.mustChangePin){showChangePinOverlay();return}' +
957
958
  'if(d.ok){location.reload();return}' +
958
959
  'if(d.locked){var boxes=document.querySelectorAll(".pin-digit");' +
959
960
  'for(var i=0;i<boxes.length;i++)boxes[i].disabled=true;' +
@@ -964,6 +965,41 @@ function multiUserLoginPageHtml() {
964
965
  'errs[1].textContent=msg;resetPin()})' +
965
966
  '.catch(function(){errs[1].textContent="Connection error";btns[1].disabled=false})}' +
966
967
  'btns[1].onclick=doLogin;' +
968
+
969
+ // Force PIN change overlay
970
+ 'function showChangePinOverlay(){' +
971
+ 'var ov=document.createElement("div");ov.className="c";' +
972
+ 'ov.style.cssText="position:fixed;inset:0;background:var(--bg,#0e0e10);z-index:9999;display:flex;align-items:center;justify-content:center";' +
973
+ 'ov.innerHTML=\'<div style="width:100%;max-width:380px;padding:24px"><h1>Set your new PIN</h1>\'+' +
974
+ '\'<div class="sub">Your temporary PIN has expired. Please set a new 6-digit PIN to continue.</div>\'+' +
975
+ '\'<div id="new-pin-boxes" class="pin-boxes">\'+' +
976
+ '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
977
+ '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
978
+ '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
979
+ '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
980
+ '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
981
+ '\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
982
+ '\'</div><input type="hidden" id="new-pin">\'+' +
983
+ '\'<button class="btn" id="save-new-pin" disabled style="margin-top:20px">Save PIN</button>\'+' +
984
+ '\'<div class="err" id="new-pin-err"></div></div>\';' +
985
+ 'document.body.appendChild(ov);' +
986
+ 'var newPinEl=ov.querySelector("#new-pin");' +
987
+ 'var saveBtn=ov.querySelector("#save-new-pin");' +
988
+ 'var errEl=ov.querySelector("#new-pin-err");' +
989
+ 'initPinBoxes("new-pin-boxes","new-pin",function(){if(!saveBtn.disabled)doSavePin()});' +
990
+ 'var nb=ov.querySelectorAll(".pin-digit");' +
991
+ 'for(var i=0;i<nb.length;i++)nb[i].addEventListener("input",function(){saveBtn.disabled=newPinEl.value.length!==6});' +
992
+ 'function doSavePin(){' +
993
+ 'saveBtn.disabled=true;errEl.textContent="";' +
994
+ 'fetch("/api/user/pin",{method:"PUT",headers:{"Content-Type":"application/json"},' +
995
+ 'body:JSON.stringify({newPin:newPinEl.value})})' +
996
+ '.then(function(r){return r.json()})' +
997
+ '.then(function(d){' +
998
+ 'if(d.ok){location.reload();return}' +
999
+ 'errEl.textContent=d.error||"Failed to save PIN";saveBtn.disabled=false})' +
1000
+ '.catch(function(){errEl.textContent="Connection error";saveBtn.disabled=false})}' +
1001
+ 'saveBtn.onclick=doSavePin}' +
1002
+
967
1003
  '</script></div></body></html>';
968
1004
  }
969
1005