clay-server 2.33.1 → 2.34.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.
Files changed (47) hide show
  1. package/lib/ask-user-mcp-server.js +120 -0
  2. package/lib/config.js +9 -13
  3. package/lib/daemon.js +116 -55
  4. package/lib/mate-datastore.js +359 -0
  5. package/lib/mates.js +2 -2
  6. package/lib/os-users.js +70 -37
  7. package/lib/project-connection.js +16 -9
  8. package/lib/project-http.js +3 -4
  9. package/lib/project-image.js +3 -2
  10. package/lib/project-mate-datastore.js +232 -0
  11. package/lib/project-sessions.js +110 -7
  12. package/lib/project-user-message.js +4 -3
  13. package/lib/project.js +126 -10
  14. package/lib/public/app.js +2 -0
  15. package/lib/public/css/mates.css +228 -11
  16. package/lib/public/css/messages.css +23 -0
  17. package/lib/public/css/mobile-nav.css +0 -14
  18. package/lib/public/css/notifications-center.css +80 -0
  19. package/lib/public/css/sidebar.css +326 -101
  20. package/lib/public/index.html +24 -29
  21. package/lib/public/modules/app-dm.js +0 -2
  22. package/lib/public/modules/app-messages.js +23 -0
  23. package/lib/public/modules/app-rendering.js +0 -2
  24. package/lib/public/modules/diff.js +21 -7
  25. package/lib/public/modules/mate-datastore-ui.js +280 -0
  26. package/lib/public/modules/mate-sidebar.js +3 -9
  27. package/lib/public/modules/mate-wizard.js +15 -15
  28. package/lib/public/modules/sidebar-mobile.js +10 -20
  29. package/lib/public/modules/sidebar-sessions.js +490 -113
  30. package/lib/public/modules/sidebar.js +8 -6
  31. package/lib/public/modules/tools.js +115 -18
  32. package/lib/public/sw.js +1 -1
  33. package/lib/sdk-bridge.js +56 -41
  34. package/lib/sdk-message-processor.js +21 -4
  35. package/lib/server.js +28 -72
  36. package/lib/sessions.js +157 -20
  37. package/lib/updater.js +2 -2
  38. package/lib/users.js +2 -2
  39. package/lib/ws-schema.js +16 -0
  40. package/lib/yoke/adapters/claude-worker.js +114 -2
  41. package/lib/yoke/adapters/claude.js +56 -5
  42. package/lib/yoke/adapters/codex.js +350 -58
  43. package/lib/yoke/index.js +93 -48
  44. package/lib/yoke/instructions.js +0 -1
  45. package/lib/yoke/mcp-bridge-server.js +14 -6
  46. package/package.json +1 -2
  47. package/lib/yoke/adapters/gemini.js +0 -709
@@ -0,0 +1,359 @@
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var config = require("./config");
4
+
5
+ var sqlite;
6
+ var _availabilityError = null;
7
+ try {
8
+ sqlite = require("node:sqlite");
9
+ } catch (e) {
10
+ sqlite = null;
11
+ _availabilityError = "Mate datastores require Node 22.13.0 or newer with node:sqlite available.";
12
+ }
13
+
14
+ function parseNodeVersion(version) {
15
+ var parts = String(version || "").split(".");
16
+ return {
17
+ major: parseInt(parts[0] || "0", 10) || 0,
18
+ minor: parseInt(parts[1] || "0", 10) || 0,
19
+ patch: parseInt(parts[2] || "0", 10) || 0,
20
+ };
21
+ }
22
+
23
+ function assertNodeVersion() {
24
+ var v = parseNodeVersion(process.versions.node);
25
+ if (v.major < 22 || (v.major === 22 && v.minor < 13)) {
26
+ throw new Error("Mate datastores require Node 22.13.0 or newer.");
27
+ }
28
+ }
29
+ try {
30
+ assertNodeVersion();
31
+ } catch (e) {
32
+ if (!_availabilityError) _availabilityError = e.message;
33
+ }
34
+
35
+ var DatabaseSync = sqlite ? sqlite.DatabaseSync : null;
36
+
37
+ var MAX_ROWS = 200;
38
+ var MAX_RESULT_BYTES = 1024 * 1024;
39
+ var DB_SIZE_WARNING_BYTES = 100 * 1024 * 1024;
40
+ var PRAGMA_BUSY_TIMEOUT = 5000;
41
+ var CLAY_META_VERSION = "1";
42
+
43
+ var _dbCache = {};
44
+
45
+ function isMateDatastoreAvailable() {
46
+ return !!DatabaseSync && !_availabilityError;
47
+ }
48
+
49
+ function getMateDatastoreAvailabilityError() {
50
+ return _availabilityError || null;
51
+ }
52
+
53
+ function assertAvailable() {
54
+ if (!isMateDatastoreAvailable()) {
55
+ throw new Error(getMateDatastoreAvailabilityError() || "Mate datastore is unavailable.");
56
+ }
57
+ }
58
+
59
+ function getMateDbPath(opts) {
60
+ if (opts && opts.dbPath) return String(opts.dbPath);
61
+ if (opts && opts.mateDir) return path.join(String(opts.mateDir), "store.db");
62
+ var userId = opts && opts.userId ? String(opts.userId) : "default";
63
+ var mateId = opts && opts.mateId ? String(opts.mateId) : null;
64
+ if (!mateId) throw new Error("Mate datastore requires a mateId.");
65
+ return path.join(config.CONFIG_DIR, "mates", userId, mateId, "store.db");
66
+ }
67
+
68
+ function ensureParentDir(filePath) {
69
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
70
+ }
71
+
72
+ function getDbSizeBytes(dbPath) {
73
+ try {
74
+ return fs.statSync(dbPath).size;
75
+ } catch (e) {
76
+ return 0;
77
+ }
78
+ }
79
+
80
+ function openDatabase(dbPath) {
81
+ var db = new DatabaseSync(dbPath);
82
+ try { db.exec("PRAGMA journal_mode = WAL"); } catch (e) {}
83
+ try { db.exec("PRAGMA foreign_keys = ON"); } catch (e2) {}
84
+ try { db.exec("PRAGMA busy_timeout = " + PRAGMA_BUSY_TIMEOUT); } catch (e3) {}
85
+ return db;
86
+ }
87
+
88
+ function initClayMeta(db) {
89
+ var now = new Date().toISOString();
90
+ db.exec("CREATE TABLE IF NOT EXISTS clay_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
91
+ var stmt = db.prepare("INSERT OR IGNORE INTO clay_meta (key, value) VALUES (?, ?)");
92
+ stmt.run("clay_meta_version", CLAY_META_VERSION);
93
+ stmt.run("created_at", now);
94
+ stmt.run("last_opened_at", now);
95
+ db.prepare("UPDATE clay_meta SET value = ? WHERE key = ?").run(now, "last_opened_at");
96
+ }
97
+
98
+ function ensureMateDatastore(opts) {
99
+ assertAvailable();
100
+ var dbPath = getMateDbPath(opts);
101
+ if (_dbCache[dbPath] && _dbCache[dbPath].db) return _dbCache[dbPath];
102
+ ensureParentDir(dbPath);
103
+ var db = openDatabase(dbPath);
104
+ initClayMeta(db);
105
+ var wrapper = {
106
+ db: db,
107
+ dbPath: dbPath,
108
+ sizeBytes: getDbSizeBytes(dbPath),
109
+ warning: getDbSizeBytes(dbPath) > DB_SIZE_WARNING_BYTES ? "Mate datastore exceeds 100 MB soft warning threshold." : null,
110
+ };
111
+ _dbCache[dbPath] = wrapper;
112
+ return wrapper;
113
+ }
114
+
115
+ function openMateDatastore(opts) {
116
+ return ensureMateDatastore(opts);
117
+ }
118
+
119
+ function closeMateDatastore(handle) {
120
+ var wrapper = unwrapHandle(handle);
121
+ if (!wrapper || !wrapper.db) return;
122
+ try {
123
+ wrapper.db.close();
124
+ } catch (e) {}
125
+ if (wrapper.dbPath && _dbCache[wrapper.dbPath]) delete _dbCache[wrapper.dbPath];
126
+ }
127
+
128
+ function closeAllMateDatastores() {
129
+ var keys = Object.keys(_dbCache);
130
+ for (var i = 0; i < keys.length; i++) {
131
+ closeMateDatastore(_dbCache[keys[i]]);
132
+ }
133
+ }
134
+
135
+ function unwrapHandle(handle) {
136
+ if (!handle) return null;
137
+ if (handle.db && handle.dbPath) return handle;
138
+ return { db: handle, dbPath: null, sizeBytes: 0, warning: null };
139
+ }
140
+
141
+ function normalizeSql(sql) {
142
+ var text = String(sql || "");
143
+ text = text.replace(/^\s*(?:--[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/g, "");
144
+ return text.trim();
145
+ }
146
+
147
+ function getFirstKeyword(sql) {
148
+ var text = normalizeSql(sql);
149
+ var match = text.match(/^([A-Za-z]+)/);
150
+ return match ? match[1].toUpperCase() : "";
151
+ }
152
+
153
+ function hasMultipleStatements(sql) {
154
+ var text = normalizeSql(sql);
155
+ if (text.indexOf(";") === -1) return false;
156
+ return !/;\s*$/.test(text) || text.slice(0, -1).indexOf(";") !== -1;
157
+ }
158
+
159
+ function isForbiddenSql(sql) {
160
+ var text = normalizeSql(sql).toUpperCase();
161
+ var banned = [
162
+ "ATTACH DATABASE",
163
+ "DETACH DATABASE",
164
+ "LOAD_EXTENSION",
165
+ "LOAD EXTENSION",
166
+ ];
167
+ for (var i = 0; i < banned.length; i++) {
168
+ if (text.indexOf(banned[i]) !== -1) return banned[i];
169
+ }
170
+ if (/PRAGMA\s+(?!table_info|table_xinfo|index_list|index_info|foreign_key_list)/i.test(text)) {
171
+ return "PRAGMA";
172
+ }
173
+ return "";
174
+ }
175
+
176
+ function isReadOnlyQuery(sql) {
177
+ var kw = getFirstKeyword(sql);
178
+ if (kw === "SELECT" || kw === "WITH") return true;
179
+ return false;
180
+ }
181
+
182
+ function isAllowedExec(sql) {
183
+ var kw = getFirstKeyword(sql);
184
+ return kw === "CREATE" || kw === "ALTER" || kw === "DROP" || kw === "INSERT" || kw === "UPDATE" || kw === "DELETE";
185
+ }
186
+
187
+ function bindParams(stmt, params) {
188
+ if (!params) return stmt;
189
+ if (!Array.isArray(params)) return stmt;
190
+ return stmt.run.apply(stmt, params);
191
+ }
192
+
193
+ function runQuery(handle, sql, params, limits) {
194
+ var wrapper = unwrapHandle(handle);
195
+ if (!wrapper || !wrapper.db) {
196
+ return makeError("MATE_DATASTORE_UNAVAILABLE", "Mate datastore is not available.");
197
+ }
198
+ var text = normalizeSql(sql);
199
+ if (!text) return makeError("SQLITE_QUERY_REJECTED", "Query SQL is required.");
200
+ if (hasMultipleStatements(text)) return makeError("SQLITE_QUERY_REJECTED", "Multiple statements are not allowed in query mode.");
201
+ var forbidden = isForbiddenSql(text);
202
+ if (forbidden) return makeError("SQLITE_FORBIDDEN", forbidden + " is not allowed in Mate datastores.");
203
+ if (!isReadOnlyQuery(text)) return makeError("SQLITE_QUERY_REJECTED", "Only SELECT and WITH queries are allowed.");
204
+
205
+ try {
206
+ var stmt = wrapper.db.prepare(text);
207
+ var rows = Array.isArray(params) ? stmt.all.apply(stmt, params) : stmt.all();
208
+ rows = sanitizeValue(rows);
209
+ var rowCount = rows.length;
210
+ var maxRows = limits && typeof limits.maxRows === "number" ? limits.maxRows : MAX_ROWS;
211
+ var truncated = false;
212
+ if (rows.length > maxRows) {
213
+ rows = rows.slice(0, maxRows);
214
+ truncated = true;
215
+ }
216
+ var result = {
217
+ ok: true,
218
+ rows: rows,
219
+ rowCount: rowCount,
220
+ truncated: truncated,
221
+ };
222
+ if (wrapper.warning) result.warning = wrapper.warning;
223
+ if (limits && limits.includeSizeInfo) {
224
+ result.sizeBytes = wrapper.sizeBytes;
225
+ }
226
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") > (limits && limits.maxBytes ? limits.maxBytes : MAX_RESULT_BYTES)) {
227
+ return makeError("SQLITE_RESULT_TOO_LARGE", "Query result exceeds the 1 MB response limit.");
228
+ }
229
+ return result;
230
+ } catch (e) {
231
+ return normalizeSqliteError(e, "SQLITE_EXEC_FAILED", "Query failed.");
232
+ }
233
+ }
234
+
235
+ function runExec(handle, sql, params, limits) {
236
+ var wrapper = unwrapHandle(handle);
237
+ if (!wrapper || !wrapper.db) {
238
+ return makeError("MATE_DATASTORE_UNAVAILABLE", "Mate datastore is not available.");
239
+ }
240
+ var text = normalizeSql(sql);
241
+ if (!text) return makeError("MATE_DATASTORE_BAD_INPUT", "SQL is required.");
242
+ if (hasMultipleStatements(text)) return makeError("SQLITE_EXEC_FAILED", "Multiple statements are not allowed in exec mode.");
243
+ var forbidden = isForbiddenSql(text);
244
+ if (forbidden) return makeError("SQLITE_FORBIDDEN", forbidden + " is not allowed in Mate datastores.");
245
+ if (!isAllowedExec(text)) return makeError("SQLITE_EXEC_FAILED", "Only CREATE, ALTER, DROP, INSERT, UPDATE, and DELETE are allowed in exec mode.");
246
+
247
+ try {
248
+ var stmt = wrapper.db.prepare(text);
249
+ var runResult = Array.isArray(params) ? stmt.run.apply(stmt, params) : stmt.run();
250
+ var result = {
251
+ ok: true,
252
+ changes: typeof runResult.changes === "number" ? runResult.changes : 0,
253
+ lastInsertRowid: typeof runResult.lastInsertRowid !== "undefined" ? sanitizeValue(runResult.lastInsertRowid) : null,
254
+ };
255
+ if (wrapper.warning) result.warning = wrapper.warning;
256
+ if (limits && limits.includeSizeInfo) {
257
+ result.sizeBytes = getDbSizeBytes(wrapper.dbPath);
258
+ }
259
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") > (limits && limits.maxBytes ? limits.maxBytes : MAX_RESULT_BYTES)) {
260
+ return makeError("SQLITE_RESULT_TOO_LARGE", "Execution result exceeds the 1 MB response limit.");
261
+ }
262
+ wrapper.sizeBytes = getDbSizeBytes(wrapper.dbPath);
263
+ if (wrapper.sizeBytes > DB_SIZE_WARNING_BYTES) {
264
+ wrapper.warning = "Mate datastore exceeds 100 MB soft warning threshold.";
265
+ result.warning = wrapper.warning;
266
+ }
267
+ return result;
268
+ } catch (e) {
269
+ return normalizeSqliteError(e, "SQLITE_EXEC_FAILED", "Execution failed.");
270
+ }
271
+ }
272
+
273
+ function listSchemaObjects(handle) {
274
+ var wrapper = unwrapHandle(handle);
275
+ if (!wrapper || !wrapper.db) {
276
+ return makeError("MATE_DATASTORE_UNAVAILABLE", "Mate datastore is not available.");
277
+ }
278
+ try {
279
+ var rows = wrapper.db.prepare(
280
+ "SELECT name, type, sql FROM sqlite_master WHERE type IN ('table', 'view', 'index') AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'clay_%' ORDER BY type, name"
281
+ ).all();
282
+ return { ok: true, objects: rows };
283
+ } catch (e) {
284
+ return normalizeSqliteError(e, "SQLITE_EXEC_FAILED", "Failed to list schema objects.");
285
+ }
286
+ }
287
+
288
+ function describeTable(handle, tableName) {
289
+ var wrapper = unwrapHandle(handle);
290
+ if (!wrapper || !wrapper.db) {
291
+ return makeError("MATE_DATASTORE_UNAVAILABLE", "Mate datastore is not available.");
292
+ }
293
+ if (!isSafeIdentifier(tableName)) {
294
+ return makeError("MATE_DATASTORE_BAD_INPUT", "Table name is invalid.");
295
+ }
296
+ try {
297
+ var info = wrapper.db.prepare("SELECT name, type, sql FROM sqlite_master WHERE name = ? AND type IN ('table', 'view')").get(tableName);
298
+ if (!info) return makeError("SQLITE_TABLE_NOT_FOUND", "Table not found.");
299
+ var columns = wrapper.db.prepare("PRAGMA table_info(" + quoteIdentifier(tableName) + ")").all();
300
+ var indexes = wrapper.db.prepare("PRAGMA index_list(" + quoteIdentifier(tableName) + ")").all();
301
+ return {
302
+ ok: true,
303
+ table: tableName,
304
+ columns: columns,
305
+ indexes: indexes,
306
+ createSql: info.sql || null,
307
+ };
308
+ } catch (e) {
309
+ return normalizeSqliteError(e, "SQLITE_EXEC_FAILED", "Failed to describe table.");
310
+ }
311
+ }
312
+
313
+ function isSafeIdentifier(name) {
314
+ return typeof name === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
315
+ }
316
+
317
+ function quoteIdentifier(name) {
318
+ return '"' + String(name).replace(/"/g, '""') + '"';
319
+ }
320
+
321
+ function makeError(code, message) {
322
+ return { ok: false, code: code, message: message };
323
+ }
324
+
325
+ function sanitizeValue(value) {
326
+ if (typeof value === "bigint") return value.toString();
327
+ if (Array.isArray(value)) return value.map(sanitizeValue);
328
+ if (value && typeof value === "object") {
329
+ var out = {};
330
+ var keys = Object.keys(value);
331
+ for (var i = 0; i < keys.length; i++) {
332
+ out[keys[i]] = sanitizeValue(value[keys[i]]);
333
+ }
334
+ return out;
335
+ }
336
+ return value;
337
+ }
338
+
339
+ function normalizeSqliteError(err, fallbackCode, fallbackMessage) {
340
+ var message = err && err.message ? String(err.message) : fallbackMessage;
341
+ var code = fallbackCode;
342
+ if (message.indexOf("no such table") !== -1) code = "SQLITE_TABLE_NOT_FOUND";
343
+ if (message.indexOf("no such column") !== -1) code = "SQLITE_QUERY_REJECTED";
344
+ return { ok: false, code: code, message: message };
345
+ }
346
+
347
+ module.exports = {
348
+ isMateDatastoreAvailable: isMateDatastoreAvailable,
349
+ getMateDatastoreAvailabilityError: getMateDatastoreAvailabilityError,
350
+ openMateDatastore: openMateDatastore,
351
+ ensureMateDatastore: ensureMateDatastore,
352
+ listSchemaObjects: listSchemaObjects,
353
+ describeTable: describeTable,
354
+ runQuery: runQuery,
355
+ runExec: runExec,
356
+ closeMateDatastore: closeMateDatastore,
357
+ closeAllMateDatastores: closeAllMateDatastores,
358
+ getMateDbPath: getMateDbPath,
359
+ };
package/lib/mates.js CHANGED
@@ -131,6 +131,7 @@ function createMate(ctx, seedData) {
131
131
  createdBy: userId,
132
132
  createdAt: Date.now(),
133
133
  seedData: seedData || {},
134
+ vendor: (seedData && seedData.vendor) || "claude",
134
135
  profile: {
135
136
  displayName: null,
136
137
  avatarColor: colors[colorIdx],
@@ -158,7 +159,7 @@ function createMate(ctx, seedData) {
158
159
  yaml += "createdAt: " + mate.createdAt + "\n";
159
160
  yaml += "relationship: " + (seedData.relationship || "assistant") + "\n";
160
161
  yaml += "activities: " + JSON.stringify(seedData.activity || []) + "\n";
161
- yaml += "autonomy: " + (seedData.autonomy || "always_ask") + "\n";
162
+ yaml += "vendor: " + (seedData.vendor || "claude") + "\n";
162
163
  fs.writeFileSync(path.join(mateDir, "mate.yaml"), yaml);
163
164
 
164
165
  // Write initial CLAUDE.md (will be replaced by interview)
@@ -172,7 +173,6 @@ function createMate(ctx, seedData) {
172
173
  if (seedData.communicationStyle && seedData.communicationStyle.length > 0) {
173
174
  claudeMd += "- Communication: " + seedData.communicationStyle.join(", ") + "\n";
174
175
  }
175
- claudeMd += "- Autonomy: " + (seedData.autonomy || "always_ask") + "\n";
176
176
  var initialIdentity = claudeMd.trimEnd();
177
177
  claudeMd += TEAM_SECTION;
178
178
  claudeMd += SESSION_MEMORY_SECTION;
package/lib/os-users.js CHANGED
@@ -3,14 +3,24 @@
3
3
 
4
4
  var fs = require("fs");
5
5
  var path = require("path");
6
- var { execSync } = require("child_process");
6
+ var execFileSync = require("child_process").execFileSync;
7
+ var _aclGrantCache = Object.create(null);
8
+
9
+ function aclCacheKey(projectPath, linuxUser) {
10
+ return path.resolve(projectPath) + "::" + linuxUser;
11
+ }
12
+
13
+ function isSafeLinuxUsername(username) {
14
+ return typeof username === "string" && /^[a-z_][a-z0-9_-]*[$]?$/.test(username);
15
+ }
7
16
 
8
17
  /**
9
18
  * Resolve Linux user info from username via getent passwd.
10
19
  * Returns { uid, gid, home, user, shell } or throws on failure.
11
20
  */
12
21
  function resolveOsUserInfo(username) {
13
- var output = execSync("getent passwd " + username, { encoding: "utf8", timeout: 5000 }).trim();
22
+ if (!isSafeLinuxUsername(username)) throw new Error("Invalid Linux username");
23
+ var output = execFileSync("getent", ["passwd", username], { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim();
14
24
  // getent passwd format: username:x:uid:gid:gecos:home:shell
15
25
  var parts = output.split(":");
16
26
  if (parts.length < 7) throw new Error("Unexpected getent output for user " + username);
@@ -74,7 +84,7 @@ function fsAsUser(op, args, osUserInfo) {
74
84
  "var buf = fs.readFileSync(f);",
75
85
  "process.stdout.write(buf.toString('base64'));",
76
86
  ].join(" ");
77
- var binOutput = execSync(process.execPath + " -e " + JSON.stringify(script), {
87
+ var binOutput = execFileSync(process.execPath, ["-e", script], {
78
88
  encoding: "utf8",
79
89
  timeout: 10000,
80
90
  uid: osUserInfo.uid,
@@ -94,7 +104,7 @@ function fsAsUser(op, args, osUserInfo) {
94
104
  throw new Error("Unknown fsAsUser operation: " + op);
95
105
  }
96
106
 
97
- var output = execSync(process.execPath + " -e " + JSON.stringify(script), {
107
+ var output = execFileSync(process.execPath, ["-e", script], {
98
108
  encoding: "utf8",
99
109
  timeout: 10000,
100
110
  uid: osUserInfo.uid,
@@ -151,7 +161,7 @@ function getAclInstallCommand() {
151
161
  */
152
162
  function checkAclSupport() {
153
163
  try {
154
- execSync("which setfacl", { encoding: "utf8", timeout: 5000, stdio: "pipe" });
164
+ execFileSync("which", ["setfacl"], { encoding: "utf8", timeout: 5000, stdio: "pipe" });
155
165
  return { available: true };
156
166
  } catch (e) {
157
167
  return { available: false, installCmd: getAclInstallCommand() };
@@ -182,19 +192,31 @@ function grantProjectAccess(projectPath, linuxUser) {
182
192
  console.log("[os-users] Skipping ACL for home directory: " + projectPath);
183
193
  return;
184
194
  }
195
+ if (!isSafeLinuxUsername(linuxUser)) {
196
+ console.error("[os-users] Invalid Linux username for ACL grant: " + linuxUser);
197
+ return;
198
+ }
199
+ var cacheKey = aclCacheKey(projectPath, linuxUser);
200
+ if (_aclGrantCache[cacheKey]) {
201
+ return;
202
+ }
185
203
  try {
186
204
  // Recursive ACL for existing files
187
- execSync("setfacl -R -m u:" + linuxUser + ":rwX " + JSON.stringify(projectPath), {
205
+ execFileSync("setfacl", ["-R", "-m", "u:" + linuxUser + ":rwX", projectPath], {
188
206
  encoding: "utf8",
189
207
  timeout: 30000,
208
+ stdio: "pipe",
190
209
  });
191
210
  // Default ACL so new files also inherit access
192
- execSync("setfacl -R -d -m u:" + linuxUser + ":rwX " + JSON.stringify(projectPath), {
211
+ execFileSync("setfacl", ["-R", "-d", "-m", "u:" + linuxUser + ":rwX", projectPath], {
193
212
  encoding: "utf8",
194
213
  timeout: 30000,
214
+ stdio: "pipe",
195
215
  });
216
+ _aclGrantCache[cacheKey] = true;
196
217
  console.log("[os-users] Granted ACL access for " + linuxUser + " on " + projectPath);
197
218
  } catch (e) {
219
+ delete _aclGrantCache[cacheKey];
198
220
  var errMsg = (e.stderr || e.message || "").toString();
199
221
  if (errMsg.indexOf("not found") !== -1 || errMsg.indexOf("ENOENT") !== -1) {
200
222
  var cmd = getAclInstallCommand();
@@ -214,15 +236,23 @@ function revokeProjectAccess(projectPath, linuxUser) {
214
236
  console.log("[os-users] Skipping ACL revoke for home directory: " + projectPath);
215
237
  return;
216
238
  }
239
+ if (!isSafeLinuxUsername(linuxUser)) {
240
+ console.error("[os-users] Invalid Linux username for ACL revoke: " + linuxUser);
241
+ return;
242
+ }
243
+ var cacheKey = aclCacheKey(projectPath, linuxUser);
217
244
  try {
218
- execSync("setfacl -R -x u:" + linuxUser + " " + JSON.stringify(projectPath), {
245
+ execFileSync("setfacl", ["-R", "-x", "u:" + linuxUser, projectPath], {
219
246
  encoding: "utf8",
220
247
  timeout: 30000,
248
+ stdio: "pipe",
221
249
  });
222
- execSync("setfacl -R -d -x u:" + linuxUser + " " + JSON.stringify(projectPath), {
250
+ execFileSync("setfacl", ["-R", "-d", "-x", "u:" + linuxUser, projectPath], {
223
251
  encoding: "utf8",
224
252
  timeout: 30000,
253
+ stdio: "pipe",
225
254
  });
255
+ delete _aclGrantCache[cacheKey];
226
256
  console.log("[os-users] Revoked ACL access for " + linuxUser + " on " + projectPath);
227
257
  } catch (e) {
228
258
  var errMsg = (e.stderr || e.message || "").toString();
@@ -260,10 +290,11 @@ function toLinuxUsername(clayUsername) {
260
290
  */
261
291
  function ensureLinger(username) {
262
292
  try {
263
- var uid = execSync("id -u " + username, { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim();
293
+ if (!isSafeLinuxUsername(username)) throw new Error("Invalid Linux username");
294
+ var uid = execFileSync("id", ["-u", username], { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim();
264
295
  var lingerFile = "/var/lib/systemd/linger/" + username;
265
296
  if (fs.existsSync(lingerFile)) return;
266
- execSync("loginctl enable-linger " + username, {
297
+ execFileSync("loginctl", ["enable-linger", username], {
267
298
  encoding: "utf8",
268
299
  timeout: 10000,
269
300
  stdio: "pipe",
@@ -279,7 +310,8 @@ function ensureLinger(username) {
279
310
  */
280
311
  function linuxUserExists(username) {
281
312
  try {
282
- execSync("id " + username, { encoding: "utf8", timeout: 5000, stdio: "pipe" });
313
+ if (!isSafeLinuxUsername(username)) return false;
314
+ execFileSync("id", [username], { encoding: "utf8", timeout: 5000, stdio: "pipe" });
283
315
  return true;
284
316
  } catch (e) {
285
317
  return false;
@@ -288,7 +320,8 @@ function linuxUserExists(username) {
288
320
 
289
321
  function getLinuxUserHome(username) {
290
322
  try {
291
- var line = execSync("getent passwd " + username, { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim();
323
+ if (!isSafeLinuxUsername(username)) return "/home/" + (username || "");
324
+ var line = execFileSync("getent", ["passwd", username], { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim();
292
325
  var parts = line.split(":");
293
326
  return parts[5] || "/home/" + username;
294
327
  } catch (e) {
@@ -298,7 +331,8 @@ function getLinuxUserHome(username) {
298
331
 
299
332
  function getLinuxUserUid(username) {
300
333
  try {
301
- var uid = execSync("id -u " + username, { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim();
334
+ if (!isSafeLinuxUsername(username)) return null;
335
+ var uid = execFileSync("id", ["-u", username], { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim();
302
336
  return parseInt(uid, 10);
303
337
  } catch (e) {
304
338
  return null;
@@ -312,11 +346,13 @@ function getLinuxUserUid(username) {
312
346
  */
313
347
  function installClaudeCli(linuxName) {
314
348
  try {
349
+ if (!isSafeLinuxUsername(linuxName)) throw new Error("Invalid Linux username");
315
350
  // Download and run the Claude CLI install script as the target user
316
- execSync(
317
- "su - " + linuxName + " -c \"curl -fsSL https://claude.ai/install.sh | bash\"",
318
- { encoding: "utf8", timeout: 60000, stdio: "pipe" }
319
- );
351
+ execFileSync("su", ["-", linuxName, "-c", "curl -fsSL https://claude.ai/install.sh | bash"], {
352
+ encoding: "utf8",
353
+ timeout: 60000,
354
+ stdio: "pipe",
355
+ });
320
356
  console.log("[os-users] Claude CLI installed for " + linuxName);
321
357
  } catch (e) {
322
358
  var msg = (e.stderr || e.message || "").trim();
@@ -326,24 +362,19 @@ function installClaudeCli(linuxName) {
326
362
 
327
363
  // Append PATH export to the user's shell config if not already present
328
364
  try {
329
- var home = "/home/" + linuxName;
330
- var rcFile;
331
- if (fs.existsSync(home + "/.zshrc")) {
332
- rcFile = "~/.zshrc";
365
+ var userInfo = resolveOsUserInfo(linuxName);
366
+ var home = userInfo.home || ("/home/" + linuxName);
367
+ var rcPath = fs.existsSync(path.join(home, ".zshrc")) ? path.join(home, ".zshrc") : path.join(home, ".bashrc");
368
+ var exportLine = 'export PATH="$HOME/.local/bin:$PATH"';
369
+ var existing = "";
370
+ try { existing = fs.readFileSync(rcPath, "utf8"); } catch (e2) {}
371
+ if (existing.indexOf(exportLine) === -1) {
372
+ var prefix = existing && !/\n$/.test(existing) ? "\n" : "";
373
+ fs.appendFileSync(rcPath, prefix + exportLine + "\n", "utf8");
374
+ try { fs.chownSync(rcPath, userInfo.uid, userInfo.gid); } catch (e3) {}
375
+ console.log("[os-users] PATH export appended to " + rcPath + " for " + linuxName);
333
376
  } else {
334
- rcFile = "~/.bashrc";
335
- }
336
-
337
- // Check if PATH export is already present before appending
338
- var checkCmd = "su - " + linuxName + " -c \"grep -qF '/.local/bin' " + rcFile + "\"";
339
- try {
340
- execSync(checkCmd, { encoding: "utf8", timeout: 5000, stdio: "pipe" });
341
- console.log("[os-users] PATH already configured in " + rcFile + " for " + linuxName);
342
- } catch (grepErr) {
343
- // grep returned non-zero, meaning the line is not present; append it
344
- var appendCmd = "su - " + linuxName + " -c 'echo \"export PATH=\\\"\\$HOME/.local/bin:\\$PATH\\\"\" >> " + rcFile + "'";
345
- execSync(appendCmd, { encoding: "utf8", timeout: 5000, stdio: "pipe" });
346
- console.log("[os-users] PATH export appended to " + rcFile + " for " + linuxName);
377
+ console.log("[os-users] PATH already configured in " + rcPath + " for " + linuxName);
347
378
  }
348
379
  } catch (e) {
349
380
  var rcMsg = (e.stderr || e.message || "").trim();
@@ -368,7 +399,7 @@ function provisionLinuxUser(clayUsername) {
368
399
  }
369
400
 
370
401
  try {
371
- execSync("useradd -m -s /bin/bash " + linuxName, {
402
+ execFileSync("useradd", ["-m", "-s", "/bin/bash", linuxName], {
372
403
  encoding: "utf8",
373
404
  timeout: 15000,
374
405
  stdio: "pipe",
@@ -453,7 +484,8 @@ function grantAllUsersAccess(projectPath, usersModule) {
453
484
  */
454
485
  function deactivateLinuxUser(linuxUsername) {
455
486
  try {
456
- execSync("usermod -L " + linuxUsername, { encoding: "utf8", timeout: 5000, stdio: "pipe" });
487
+ if (!isSafeLinuxUsername(linuxUsername)) throw new Error("Invalid Linux username");
488
+ execFileSync("usermod", ["-L", linuxUsername], { encoding: "utf8", timeout: 5000, stdio: "pipe" });
457
489
  console.log("[os-users] Deactivated Linux user: " + linuxUsername);
458
490
  return { ok: true };
459
491
  } catch (e) {
@@ -493,4 +525,5 @@ module.exports = {
493
525
  isHomeDirectory: isHomeDirectory,
494
526
  getLinuxUserHome: getLinuxUserHome,
495
527
  getLinuxUserUid: getLinuxUserUid,
528
+ isSafeLinuxUsername: isSafeLinuxUsername,
496
529
  };
@@ -52,12 +52,27 @@ function attachConnection(ctx) {
52
52
  var getLatestVersion = ctx.getLatestVersion;
53
53
  var getTitle = ctx.getTitle;
54
54
  var getProject = ctx.getProject;
55
+ var warmup = ctx.warmup;
56
+
57
+ // Adapters are initialized lazily: the first websocket connection into
58
+ // this project triggers warmup. Without this guard we would either keep
59
+ // the old eager behavior (30+ Codex processes at daemon start) or run
60
+ // warmup once per reconnect.
61
+ var _warmedUp = false;
55
62
 
56
63
  function handleConnection(ws, wsUser, handleMessage, handleDisconnection) {
57
64
  ws._clayUser = wsUser || null;
58
65
  clients.add(ws);
59
66
  broadcastClientCount();
60
67
 
68
+ if (!_warmedUp) {
69
+ _warmedUp = true;
70
+ if (typeof warmup === "function") {
71
+ try { warmup(); }
72
+ catch (e) { console.error("[project-connection] warmup failed for " + slug + ":", e && e.message ? e.message : e); }
73
+ }
74
+ }
75
+
61
76
  var loopState = _loop.loopState;
62
77
  var loopRegistry = _loop.loopRegistry;
63
78
 
@@ -67,16 +82,7 @@ function attachConnection(ctx) {
67
82
  setTimeout(function() { _loop.resumeLoop(); }, 500);
68
83
  }
69
84
 
70
- // Auto-assign owner if project has none and a user connects (e.g. IPC-added projects)
71
85
  var projectOwnerId = getProjectOwnerId();
72
- if (!projectOwnerId && ws._clayUser && ws._clayUser.id && !isMate) {
73
- setProjectOwnerId(ws._clayUser.id);
74
- projectOwnerId = ws._clayUser.id;
75
- if (opts.onProjectOwnerChanged) {
76
- opts.onProjectOwnerChanged(slug, projectOwnerId);
77
- }
78
- console.log("[project] Auto-assigned owner for " + slug + ": " + projectOwnerId);
79
- }
80
86
 
81
87
  // Send cached state
82
88
  var _userId = ws._clayUser ? ws._clayUser.id : null;
@@ -140,6 +146,7 @@ function attachConnection(ctx) {
140
146
  ownerId: s.ownerId || null,
141
147
  sessionVisibility: s.sessionVisibility || "shared",
142
148
  bookmarked: !!s.bookmarked,
149
+ favoriteOrder: typeof s.favoriteOrder === "number" ? s.favoriteOrder : null,
143
150
  };
144
151
  }),
145
152
  });