clay-server 2.21.0-beta.2 → 2.21.0-beta.4

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/daemon.js CHANGED
@@ -1264,7 +1264,8 @@ function startListening() {
1264
1264
  saveConfig(config);
1265
1265
 
1266
1266
  // Auto-provision Linux accounts on startup if OS users mode is enabled.
1267
- // Run in setTimeout so the event loop stays free for IPC during startup.
1267
+ // ACLs are NOT re-applied on every startup (too slow with recursive setfacl).
1268
+ // ACLs are set when: projects are added, users are added, or visibility changes.
1268
1269
  if (config.osUsers) {
1269
1270
  setTimeout(function () {
1270
1271
  try { ensureProjectsDir(); } catch (e) {}
@@ -1272,6 +1273,17 @@ function startListening() {
1272
1273
  var provResult = provisionAllUsers(usersModule);
1273
1274
  if (provResult.provisioned.length > 0) {
1274
1275
  console.log("[daemon] Auto-provisioned " + provResult.provisioned.length + " Linux account(s) on startup");
1276
+ // Only set ACLs for newly provisioned users (not all users on all projects)
1277
+ for (var pi = 0; pi < config.projects.length; pi++) {
1278
+ var proj = config.projects[pi];
1279
+ if ((proj.visibility || "public") === "public") {
1280
+ for (var ni = 0; ni < provResult.provisioned.length; ni++) {
1281
+ try {
1282
+ grantProjectAccess(proj.path, provResult.provisioned[ni].linuxUser);
1283
+ } catch (e) {}
1284
+ }
1285
+ }
1286
+ }
1275
1287
  }
1276
1288
  if (provResult.errors.length > 0) {
1277
1289
  console.error("[daemon] Failed to provision " + provResult.errors.length + " account(s)");
@@ -1279,32 +1291,7 @@ function startListening() {
1279
1291
  } catch (provErr) {
1280
1292
  console.error("[daemon] Startup provisioning error:", provErr.message);
1281
1293
  }
1282
- // Set up ACLs for all existing projects on startup
1283
- for (var pi = 0; pi < config.projects.length; pi++) {
1284
- var proj = config.projects[pi];
1285
- try {
1286
- if (proj.ownerId) {
1287
- var ownerUser = usersModule.findUserById(proj.ownerId);
1288
- if (ownerUser && ownerUser.linuxUser) {
1289
- grantProjectAccess(proj.path, ownerUser.linuxUser);
1290
- }
1291
- }
1292
- if ((proj.visibility || "public") === "public") {
1293
- grantAllUsersAccess(proj.path, usersModule);
1294
- } else {
1295
- var projAllowed = proj.allowedUsers || [];
1296
- for (var ai = 0; ai < projAllowed.length; ai++) {
1297
- var allowedUser = usersModule.findUserById(projAllowed[ai]);
1298
- if (allowedUser && allowedUser.linuxUser) {
1299
- grantProjectAccess(proj.path, allowedUser.linuxUser);
1300
- }
1301
- }
1302
- }
1303
- } catch (aclErr) {
1304
- console.error("[daemon] Startup ACL error for " + proj.path + ":", aclErr.message);
1305
- }
1306
- }
1307
- console.log("[daemon] Startup OS users provisioning complete.");
1294
+ console.log("[daemon] Startup OS users check complete.");
1308
1295
  }, 100);
1309
1296
  }
1310
1297
 
package/lib/sdk-bridge.js CHANGED
@@ -814,9 +814,36 @@ function createSDKBridge(opts) {
814
814
  * Mirrors the in-process startQuery flow but delegates SDK execution to the worker.
815
815
  */
816
816
  async function startQueryViaWorker(session, text, images, linuxUser) {
817
- // Save for auto-retry on session-not-found
818
- session._lastQueryText = text;
819
- session._lastQueryImages = images;
817
+ // Pre-copy CLI session file BEFORE spawning worker.
818
+ // Must happen before spawn so execSync doesn't block the event loop
819
+ // while worker is alive (which causes ready/exit race conditions).
820
+ if (session.cliSessionId && linuxUser) {
821
+ try {
822
+ var configMod = require("./config");
823
+ var osUsersMod = require("./os-users");
824
+ var originalHome = configMod.REAL_HOME || require("os").homedir();
825
+ var linuxUserHome = osUsersMod.getLinuxUserHome(linuxUser);
826
+ if (originalHome !== linuxUserHome) {
827
+ var projectSlug = (cwd || "").replace(/\//g, "-");
828
+ var sessionFileName = session.cliSessionId + ".jsonl";
829
+ var srcFile = path.join(originalHome, ".claude", "projects", projectSlug, sessionFileName);
830
+ var dstDir = path.join(linuxUserHome, ".claude", "projects", projectSlug);
831
+ var dstFile = path.join(dstDir, sessionFileName);
832
+ if (fs.existsSync(srcFile) && !fs.existsSync(dstFile)) {
833
+ fs.mkdirSync(dstDir, { recursive: true });
834
+ fs.copyFileSync(srcFile, dstFile);
835
+ var uid = osUsersMod.getLinuxUserUid(linuxUser);
836
+ if (uid != null) {
837
+ try { require("child_process").execSync("chown " + uid + " " + JSON.stringify(dstFile)); } catch (e2) {}
838
+ }
839
+ console.log("[sdk-bridge] Pre-copied CLI session " + session.cliSessionId + " to " + linuxUser);
840
+ }
841
+ }
842
+ } catch (copyErr) {
843
+ console.log("[sdk-bridge] Session pre-copy skipped:", copyErr.message);
844
+ }
845
+ }
846
+
820
847
  var worker;
821
848
  try {
822
849
  worker = spawnWorker(linuxUser);
@@ -952,7 +979,7 @@ function createSDKBridge(opts) {
952
979
  sendAndRecord(session, { type: "done", code: 0 });
953
980
  sm.broadcastSessionList();
954
981
  }
955
- cleanupSessionWorker(session);
982
+ cleanupSessionWorker(session, worker);
956
983
  if (session.onQueryComplete) {
957
984
  try { session.onQueryComplete(session); } catch (err) {
958
985
  console.error("[sdk-bridge] onQueryComplete error:", err.message || err);
@@ -965,63 +992,10 @@ function createSDKBridge(opts) {
965
992
  var qerrLower = (msg.error || "").toLowerCase();
966
993
  var isSessionNotFound = qerrLower.indexOf("no conversation found") !== -1
967
994
  || qerrLower.indexOf("session not found") !== -1;
968
- if (isSessionNotFound && !session._sessionMigrateRetried) {
969
- // Auto-retry: copy CLI session file to OS user's home, then resume
970
- console.log("[sdk-bridge] Session not found for OS user, migrating CLI session for session " + session.localId);
971
- session._sessionMigrateRetried = true;
972
- // Try to copy the CLI session file from original user to OS user
973
- var migratedOk = false;
974
- if (session.cliSessionId && session.lastLinuxUser) {
975
- try {
976
- var configMod = require("./config");
977
- var osUsersMod = require("./os-users");
978
- var originalHome = configMod.REAL_HOME || require("os").homedir();
979
- var linuxUserHome = osUsersMod.getLinuxUserHome ? osUsersMod.getLinuxUserHome(session.lastLinuxUser) : "/home/" + session.lastLinuxUser;
980
- var projectDir = session.cwd || "";
981
- var projectSlug = projectDir.replace(/\//g, "-");
982
- var srcFile = path.join(originalHome, ".claude", "projects", projectSlug, session.cliSessionId + ".jsonl");
983
- var dstDir = path.join(linuxUserHome, ".claude", "projects", projectSlug);
984
- var dstFile = path.join(dstDir, session.cliSessionId + ".jsonl");
985
- if (fs.existsSync(srcFile) && !fs.existsSync(dstFile)) {
986
- fs.mkdirSync(dstDir, { recursive: true });
987
- fs.copyFileSync(srcFile, dstFile);
988
- // Fix ownership so the OS user can read/write it
989
- var uid = osUsersMod.getLinuxUserUid ? osUsersMod.getLinuxUserUid(session.lastLinuxUser) : null;
990
- if (uid != null) {
991
- try { require("child_process").execSync("chown -R " + uid + " " + JSON.stringify(dstDir)); } catch (e2) {}
992
- }
993
- console.log("[sdk-bridge] Copied CLI session " + session.cliSessionId + " to " + dstFile);
994
- migratedOk = true;
995
- } else if (fs.existsSync(dstFile)) {
996
- migratedOk = true; // already there
997
- }
998
- } catch (copyErr) {
999
- console.error("[sdk-bridge] Failed to copy CLI session:", copyErr.message);
1000
- }
1001
- }
1002
- // Detach old worker so its exit event doesn't interfere with the new one
1003
- if (session.worker) {
1004
- session.worker.messageHandlers = [];
1005
- session.worker.kill();
1006
- session.worker = null;
1007
- }
1008
- session.queryInstance = null;
1009
- session.messageQueue = null;
1010
- session.abortController = null;
1011
- // Ensure client knows we're still processing (re-send status)
1012
- session.isProcessing = true;
1013
- onProcessingChanged();
1014
- send({ type: "status", status: "processing" });
1015
- // If migration failed, clear cliSessionId so it starts fresh
1016
- if (!migratedOk) {
1017
- session.cliSessionId = null;
1018
- }
1019
- // Re-run the query (with resume if migration succeeded)
1020
- var retryText = session._lastQueryText || "";
1021
- var retryImages = session._lastQueryImages || undefined;
1022
- var retryLinuxUser = session.lastLinuxUser || null;
1023
- startQuery(session, retryText, retryImages, retryLinuxUser);
1024
- break;
995
+ if (isSessionNotFound) {
996
+ // Pre-copy should have handled this. Clear stale cliSessionId
997
+ // so next message starts a fresh conversation in the same UI session.
998
+ session.cliSessionId = null;
1025
999
  }
1026
1000
  if (session.isProcessing) {
1027
1001
  session.isProcessing = false;
@@ -1086,7 +1060,7 @@ function createSDKBridge(opts) {
1086
1060
  }
1087
1061
  sm.broadcastSessionList();
1088
1062
  }
1089
- cleanupSessionWorker(session);
1063
+ cleanupSessionWorker(session, worker);
1090
1064
  // Auto-continue on rate limit (scheduler sessions, or user setting)
1091
1065
  var workerDidScheduleAC = false;
1092
1066
  var workerACEnabled = session.onQueryComplete || (typeof opts.getAutoContinueSetting === "function" && opts.getAutoContinueSetting(session));
@@ -1154,7 +1128,13 @@ function createSDKBridge(opts) {
1154
1128
  });
1155
1129
  }
1156
1130
 
1157
- function cleanupSessionWorker(session) {
1131
+ function cleanupSessionWorker(session, fromWorker) {
1132
+ // If called from a specific worker's exit/error handler, only cleanup if
1133
+ // that worker is still the session's current worker. Prevents stale
1134
+ // worker exit events from killing a newer worker.
1135
+ if (fromWorker && session.worker && session.worker !== fromWorker) {
1136
+ return;
1137
+ }
1158
1138
  session.queryInstance = null;
1159
1139
  session.messageQueue = null;
1160
1140
  session.abortController = null;
@@ -1254,9 +1234,9 @@ function createSDKBridge(opts) {
1254
1234
  // Auto-approve safe Bash commands (read-only, non-destructive)
1255
1235
  if (toolName === "Bash" && input && input.command) {
1256
1236
  var cmd = input.command.trim();
1257
- var firstWord = cmd.split(/[\s;|&]/)[0];
1258
1237
  var safeBashCommands = {
1259
- ls: true, cat: true, head: true, tail: true, wc: true, file: true,
1238
+ // Navigation (harmless on its own, checked in compound commands below)
1239
+ cd: true, pushd: true, popd: true,
1260
1240
  // File/dir inspection
1261
1241
  ls: true, cat: true, head: true, tail: true, wc: true, file: true,
1262
1242
  stat: true, find: true, tree: true, du: true, df: true,
@@ -1293,7 +1273,16 @@ function createSDKBridge(opts) {
1293
1273
  nslookup: true, host: true, ping: true, traceroute: true,
1294
1274
  curl: true, wget: true, http: true,
1295
1275
  };
1296
- if (safeBashCommands[firstWord]) {
1276
+ // Split compound commands (&&, ||, ;, |) and check ALL segments
1277
+ var segments = cmd.split(/\s*(?:&&|\|\||[;|])\s*/);
1278
+ var allSafe = true;
1279
+ for (var si = 0; si < segments.length; si++) {
1280
+ var seg = segments[si].trim();
1281
+ if (!seg) continue;
1282
+ var firstWord = seg.split(/\s/)[0];
1283
+ if (!safeBashCommands[firstWord]) { allSafe = false; break; }
1284
+ }
1285
+ if (allSafe) {
1297
1286
  return Promise.resolve({ behavior: "allow", updatedInput: input });
1298
1287
  }
1299
1288
  }
package/lib/sdk-worker.js CHANGED
@@ -220,31 +220,6 @@ async function handleQueryStart(msg) {
220
220
  messageQueue.push(msg.prompt);
221
221
  }
222
222
 
223
- // If resuming a session that doesn't exist for this OS user, try to migrate
224
- // the session file from the original user's home directory.
225
- if (msg.options && msg.options.resume && msg.originalHome && msg.projectPath) {
226
- try {
227
- var fs = require("fs");
228
- var homePath = require("os").homedir();
229
- if (homePath !== msg.originalHome) {
230
- var projDirName = msg.projectPath.replace(/\//g, "-");
231
- var sessionFile = msg.options.resume + ".jsonl";
232
- var destDir = path.join(homePath, ".claude", "projects", projDirName);
233
- var destFile = path.join(destDir, sessionFile);
234
- if (!fs.existsSync(destFile)) {
235
- var srcFile = path.join(msg.originalHome, ".claude", "projects", projDirName, sessionFile);
236
- if (fs.existsSync(srcFile)) {
237
- fs.mkdirSync(destDir, { recursive: true });
238
- fs.copyFileSync(srcFile, destFile);
239
- try { fs.writeSync(2, "[sdk-worker] Migrated session " + msg.options.resume + " from " + msg.originalHome + "\n"); } catch (e2) {}
240
- }
241
- }
242
- }
243
- } catch (migrateErr) {
244
- try { fs.writeSync(2, "[sdk-worker] Session migration failed: " + migrateErr.message + "\n"); } catch (e2) {}
245
- }
246
- }
247
-
248
223
  // Build query options (callbacks are local, everything else from daemon)
249
224
  var options = msg.options || {};
250
225
  options.abortController = abortController;
package/lib/sessions.js CHANGED
@@ -627,10 +627,12 @@ function createSessionManager(opts) {
627
627
  return { hits: hits, total: history.length };
628
628
  }
629
629
 
630
+ var _migrationFailedIds = {};
630
631
  function migrateSessionTitles(getSDK, migrateCwd) {
631
632
  var candidates = [];
632
633
  sessions.forEach(function(s) {
633
- if (s.cliSessionId && s.title && s.title !== "New Session" && s.title !== "Resumed session") {
634
+ if (s.cliSessionId && s.title && s.title !== "New Session" && s.title !== "Resumed session"
635
+ && !_migrationFailedIds[s.cliSessionId]) {
634
636
  candidates.push({ cliSessionId: s.cliSessionId, title: s.title });
635
637
  }
636
638
  });
@@ -647,18 +649,28 @@ function createSessionManager(opts) {
647
649
  return sdkTitles[item.cliSessionId] !== item.title;
648
650
  });
649
651
  if (toMigrate.length === 0) return;
652
+ var migrated = 0;
653
+ var failed = 0;
650
654
  var chain = Promise.resolve();
651
655
  for (var j = 0; j < toMigrate.length; j++) {
652
656
  (function(item) {
653
657
  chain = chain.then(function() {
654
- return sdkMod.renameSession(item.cliSessionId, item.title, { dir: migrateCwd }).catch(function(e) {
655
- console.error("[session] Migration failed for " + item.cliSessionId + ":", e.message);
658
+ return sdkMod.renameSession(item.cliSessionId, item.title, { dir: migrateCwd }).then(function() {
659
+ migrated++;
660
+ }).catch(function(e) {
661
+ failed++;
662
+ _migrationFailedIds[item.cliSessionId] = true;
656
663
  });
657
664
  });
658
665
  })(toMigrate[j]);
659
666
  }
660
667
  chain.then(function() {
661
- console.log("[session] Migrated " + toMigrate.length + " session title(s) to SDK format");
668
+ if (migrated > 0) {
669
+ console.log("[session] Migrated " + migrated + " session title(s) to SDK format");
670
+ }
671
+ if (failed > 0) {
672
+ console.log("[session] Skipped " + failed + " session(s) (CLI session not found for current user)");
673
+ }
662
674
  }).catch(function(e) {
663
675
  console.error("[session] Migration chain failed:", e.message || e);
664
676
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.21.0-beta.2",
3
+ "version": "2.21.0-beta.4",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",