clay-server 2.20.1-beta.9 → 2.21.0-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.
package/bin/cli.js CHANGED
@@ -1641,7 +1641,7 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1641
1641
  // ==============================
1642
1642
  // Dev mode — foreground daemon with file watching
1643
1643
  // ==============================
1644
- async function devMode(mode, keepAwake, existingPinHash) {
1644
+ async function devMode(mode, keepAwake, existingPinHash, wantOsUsers) {
1645
1645
  var ip = getLocalIP();
1646
1646
  var hasTls = false;
1647
1647
  var hasBuiltinCert = false;
@@ -1723,6 +1723,7 @@ async function devMode(mode, keepAwake, existingPinHash) {
1723
1723
  mode: mode || "single",
1724
1724
  setupCompleted: true,
1725
1725
  projects: allProjects,
1726
+ osUsers: wantOsUsers || (prevDevConfig ? (prevDevConfig.osUsers || false) : false),
1726
1727
  };
1727
1728
 
1728
1729
  ensureConfigDir();
@@ -2594,6 +2595,10 @@ function showSettingsMenu(config, ip) {
2594
2595
  log(sym.bar + " " + a.dim + "Setting ACLs for " + cfgProjects.length + " project(s)..." + a.reset);
2595
2596
  for (var pi = 0; pi < cfgProjects.length; pi++) {
2596
2597
  var proj = cfgProjects[pi];
2598
+ if (osUsersLib.isHomeDirectory(proj.path)) {
2599
+ log(sym.bar + " " + a.dim + "~ " + (proj.slug || proj.path) + " (home dir, skipped)" + a.reset);
2600
+ continue;
2601
+ }
2597
2602
  try {
2598
2603
  if (proj.visibility === "public") {
2599
2604
  osUsersLib.grantAllUsersAccess(proj.path, usersLib);
@@ -2873,11 +2878,11 @@ var currentVersion = require("../package.json").version;
2873
2878
  // No config — go through setup (disclaimer, port, mode, etc.)
2874
2879
  if (!devConfig) {
2875
2880
  setup(function (mode, keepAwake, wantOsUsers) {
2876
- devMode(mode, keepAwake, null);
2881
+ devMode(mode, keepAwake, null, wantOsUsers);
2877
2882
  });
2878
2883
  } else {
2879
2884
  // Reuse existing config (repeat run)
2880
- await devMode(devConfig.mode || "single", devConfig.keepAwake || false, devConfig.pinHash || null);
2885
+ await devMode(devConfig.mode || "single", devConfig.keepAwake || false, devConfig.pinHash || null, devConfig.osUsers || false);
2881
2886
  }
2882
2887
  return;
2883
2888
  }
package/lib/daemon.js CHANGED
@@ -1399,7 +1399,7 @@ function spawnAndRestart() {
1399
1399
  var updateHandoff = false; // true when shutting down for update (new daemon already spawned)
1400
1400
 
1401
1401
  function gracefulShutdown() {
1402
- console.log("[daemon] Shutting down...");
1402
+ try { console.log("[daemon] Shutting down..."); } catch (e) {}
1403
1403
  var exitCode = updateHandoff ? 120 : 0; // 120 = update handoff, don't auto-restart
1404
1404
 
1405
1405
  if (caffeinateProc) {
@@ -1426,13 +1426,13 @@ function gracefulShutdown() {
1426
1426
  }
1427
1427
 
1428
1428
  relay.server.close(function () {
1429
- console.log("[daemon] Server closed");
1429
+ try { console.log("[daemon] Server closed"); } catch (e) {}
1430
1430
  process.exit(exitCode);
1431
1431
  });
1432
1432
 
1433
1433
  // Force exit after 5 seconds
1434
1434
  setTimeout(function () {
1435
- console.error("[daemon] Forced exit after timeout");
1435
+ try { console.error("[daemon] Forced exit after timeout"); } catch (e) {}
1436
1436
  process.exit(1);
1437
1437
  }, 5000);
1438
1438
  }
@@ -1462,7 +1462,14 @@ process.on("uncaughtException", function (err) {
1462
1462
  // A single session's SDK write was aborted (e.g. stream closed before
1463
1463
  // write completed). This is recoverable, so do NOT tear down the whole
1464
1464
  // daemon and kill every other session.
1465
- console.error("[daemon] Suppressed AbortError (single-session failure):", errMsg);
1465
+ try { console.error("[daemon] Suppressed AbortError (single-session failure):", errMsg); } catch (e) {}
1466
+ return;
1467
+ }
1468
+
1469
+ // EIO/EPIPE on stdout/stderr when parent process (dev mode CLI) dies.
1470
+ // Not fatal for the daemon itself.
1471
+ var isIOError = errMsg.indexOf("EIO") !== -1 || errMsg.indexOf("EPIPE") !== -1;
1472
+ if (isIOError) {
1466
1473
  return;
1467
1474
  }
1468
1475
 
package/lib/os-users.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Used by sdk-bridge.js (worker spawning), terminal-manager.js, and project.js (file ops).
3
3
 
4
4
  var fs = require("fs");
5
+ var path = require("path");
5
6
  var { execSync } = require("child_process");
6
7
 
7
8
  /**
@@ -157,11 +158,30 @@ function checkAclSupport() {
157
158
  }
158
159
  }
159
160
 
161
+ /**
162
+ * Check if a path is a user's home directory (e.g. /home/chad, /root).
163
+ * Running recursive setfacl on a home dir is dangerous and slow.
164
+ */
165
+ function isHomeDirectory(dirPath) {
166
+ var resolved = path.resolve(dirPath);
167
+ // /root
168
+ if (resolved === "/root") return true;
169
+ // /home/username (exactly two levels)
170
+ var parts = resolved.split("/");
171
+ if (parts.length === 3 && parts[0] === "" && parts[1] === "home" && parts[2]) return true;
172
+ return false;
173
+ }
174
+
160
175
  /**
161
176
  * Grant a Linux user ACL access (rwX) to a project directory.
162
177
  * Uses setfacl to add recursive + default ACL entries.
178
+ * Skips home directories to avoid slow recursive ACL on large trees.
163
179
  */
164
180
  function grantProjectAccess(projectPath, linuxUser) {
181
+ if (isHomeDirectory(projectPath)) {
182
+ console.log("[os-users] Skipping ACL for home directory: " + projectPath);
183
+ return;
184
+ }
165
185
  try {
166
186
  // Recursive ACL for existing files
167
187
  execSync("setfacl -R -m u:" + linuxUser + ":rwX " + JSON.stringify(projectPath), {
@@ -190,6 +210,10 @@ function grantProjectAccess(projectPath, linuxUser) {
190
210
  * Revoke a Linux user's ACL access from a project directory.
191
211
  */
192
212
  function revokeProjectAccess(projectPath, linuxUser) {
213
+ if (isHomeDirectory(projectPath)) {
214
+ console.log("[os-users] Skipping ACL revoke for home directory: " + projectPath);
215
+ return;
216
+ }
193
217
  try {
194
218
  execSync("setfacl -R -x u:" + linuxUser + " " + JSON.stringify(projectPath), {
195
219
  encoding: "utf8",
@@ -242,6 +266,25 @@ function linuxUserExists(username) {
242
266
  }
243
267
  }
244
268
 
269
+ function getLinuxUserHome(username) {
270
+ try {
271
+ var line = execSync("getent passwd " + username, { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim();
272
+ var parts = line.split(":");
273
+ return parts[5] || "/home/" + username;
274
+ } catch (e) {
275
+ return "/home/" + username;
276
+ }
277
+ }
278
+
279
+ function getLinuxUserUid(username) {
280
+ try {
281
+ var uid = execSync("id -u " + username, { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim();
282
+ return parseInt(uid, 10);
283
+ } catch (e) {
284
+ return null;
285
+ }
286
+ }
287
+
245
288
  /**
246
289
  * Install Claude CLI for a Linux user account.
247
290
  * Downloads and runs the install script, then ensures PATH is configured.
@@ -424,4 +467,7 @@ module.exports = {
424
467
  installClaudeCli: installClaudeCli,
425
468
  deactivateLinuxUser: deactivateLinuxUser,
426
469
  ensureProjectsDir: ensureProjectsDir,
470
+ isHomeDirectory: isHomeDirectory,
471
+ getLinuxUserHome: getLinuxUserHome,
472
+ getLinuxUserUid: getLinuxUserUid,
427
473
  };
package/lib/sdk-bridge.js CHANGED
@@ -613,22 +613,32 @@ function createSDKBridge(opts) {
613
613
  var dirs = [];
614
614
  while (dir !== path.dirname(dir)) {
615
615
  dirs.push(dir);
616
- // Stop once we leave the npm cache tree
617
- if (dir.indexOf(".npm") === -1 && dir.indexOf("node_modules") === -1) break;
618
616
  dir = path.dirname(dir);
619
617
  }
618
+ // Open o+rx on each ancestor so non-root users can traverse the path
619
+ // (e.g. /root/.npm/_npx/.../node_modules/clay-server needs /root to be o+x)
620
620
  for (var di = 0; di < dirs.length; di++) {
621
621
  try {
622
622
  var st = fs.statSync(dirs[di]);
623
- // Add o+rx if not already present
624
- if ((st.mode & 0o005) !== 0o005) {
625
- fs.chmodSync(dirs[di], st.mode | 0o005);
623
+ // Add o+x (traverse) to all ancestors, o+rx to npm cache dirs
624
+ var isNpmDir = dirs[di].indexOf(".npm") !== -1 || dirs[di].indexOf("node_modules") !== -1;
625
+ var needed = isNpmDir ? 0o005 : 0o001; // rx for npm dirs, just x for ancestors like /root
626
+ if ((st.mode & needed) !== needed) {
627
+ fs.chmodSync(dirs[di], st.mode | needed);
626
628
  }
627
629
  } catch (e) {}
628
630
  }
629
- // Recursively make the package contents readable
631
+ // Recursively make the package AND hoisted dependencies readable.
632
+ // npm/npx may hoist deps (e.g. @anthropic-ai/claude-agent-sdk) to the
633
+ // parent node_modules/ instead of inside clay-server/node_modules/.
630
634
  var { execSync: chmodExec } = require("child_process");
631
- chmodExec("chmod -R o+rX " + JSON.stringify(pkgDir), { stdio: "ignore", timeout: 5000 });
635
+ // Find the top-level node_modules that contains clay-server
636
+ var topNodeModules = path.join(pkgDir, "..");
637
+ if (path.basename(topNodeModules) === "node_modules") {
638
+ chmodExec("chmod -R o+rX " + JSON.stringify(topNodeModules), { stdio: "ignore", timeout: 15000 });
639
+ } else {
640
+ chmodExec("chmod -R o+rX " + JSON.stringify(pkgDir), { stdio: "ignore", timeout: 5000 });
641
+ }
632
642
  } catch (e) {}
633
643
  })();
634
644
 
@@ -697,15 +707,17 @@ function createSDKBridge(opts) {
697
707
  // Set socket permissions so the target user can connect
698
708
  try { fs.chmodSync(socketPath, 0o777); } catch (e) {}
699
709
 
700
- // Spawn worker process as the target Linux user
701
- var workerEnv = {
710
+ // Spawn worker process as the target Linux user.
711
+ // Inherit full env from daemon, override user-specific vars.
712
+ var workerEnv = Object.assign({}, process.env, {
702
713
  HOME: userInfo.home,
703
714
  USER: linuxUser,
704
- PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
705
- NODE_PATH: process.env.NODE_PATH || "",
706
- LANG: process.env.LANG || "en_US.UTF-8",
707
- };
715
+ LOGNAME: linuxUser,
716
+ });
708
717
 
718
+ console.log("[sdk-bridge] Spawning worker: uid=" + userInfo.uid + " gid=" + userInfo.gid + " cwd=" + cwd + " socket=" + socketPath);
719
+ console.log("[sdk-bridge] Worker script: " + WORKER_SCRIPT);
720
+ console.log("[sdk-bridge] Node: " + process.execPath);
709
721
  worker.process = spawn(process.execPath, [WORKER_SCRIPT, socketPath], {
710
722
  uid: userInfo.uid,
711
723
  gid: userInfo.gid,
@@ -725,8 +737,24 @@ function createSDKBridge(opts) {
725
737
  });
726
738
 
727
739
  worker.process.on("exit", function(code, signal) {
728
- console.log("[sdk-bridge] Worker for " + linuxUser + " exited (code=" + code + ", signal=" + signal + ")");
740
+ console.log("[sdk-bridge] Worker for " + linuxUser + " exited (code=" + code + ", signal=" + signal + ")" + (worker._stderrBuf ? " stderr: " + worker._stderrBuf.trim() : ""));
741
+ // Reject readyPromise if worker dies before becoming ready
742
+ if (!worker.ready && worker._readyResolve) {
743
+ worker._readyResolve = null;
744
+ // Let the readyPromise hang; the query_error handler will clean up
745
+ }
729
746
  // Notify message handlers about unexpected exit so sessions don't hang
747
+ if (code === 0 && !worker.ready) {
748
+ // Worker exited cleanly before sending "ready" — something is wrong
749
+ for (var h = 0; h < worker.messageHandlers.length; h++) {
750
+ worker.messageHandlers[h]({
751
+ type: "query_error",
752
+ error: "Worker exited before ready (code=0). stderr: " + (worker._stderrBuf || "(none)"),
753
+ exitCode: 0,
754
+ stderr: worker._stderrBuf || null,
755
+ });
756
+ }
757
+ }
730
758
  if (code !== 0 && code !== null) {
731
759
  var stderrText = worker._stderrBuf || "";
732
760
  for (var h = 0; h < worker.messageHandlers.length; h++) {
@@ -786,6 +814,9 @@ function createSDKBridge(opts) {
786
814
  * Mirrors the in-process startQuery flow but delegates SDK execution to the worker.
787
815
  */
788
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;
789
820
  var worker;
790
821
  try {
791
822
  worker = spawnWorker(linuxUser);
@@ -929,7 +960,69 @@ function createSDKBridge(opts) {
929
960
  }
930
961
  break;
931
962
 
932
- case "query_error":
963
+ case "query_error": {
964
+ // Check session-not-found before isProcessing gate (it can arrive after processing is cleared)
965
+ var qerrLower = (msg.error || "").toLowerCase();
966
+ var isSessionNotFound = qerrLower.indexOf("no conversation found") !== -1
967
+ || 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;
1025
+ }
933
1026
  if (session.isProcessing) {
934
1027
  session.isProcessing = false;
935
1028
  onProcessingChanged();
@@ -1014,6 +1107,7 @@ function createSDKBridge(opts) {
1014
1107
  }
1015
1108
  }
1016
1109
  break;
1110
+ }
1017
1111
 
1018
1112
  case "model_changed":
1019
1113
  sm.currentModel = msg.model;
@@ -1055,6 +1149,8 @@ function createSDKBridge(opts) {
1055
1149
  prompt: initialMessage,
1056
1150
  options: queryOptions,
1057
1151
  singleTurn: !!session.singleTurn,
1152
+ originalHome: require("./config").REAL_HOME || null,
1153
+ projectPath: session.cwd || null,
1058
1154
  });
1059
1155
  }
1060
1156
 
package/lib/sdk-worker.js CHANGED
@@ -4,6 +4,9 @@
4
4
  //
5
5
  // Usage: node sdk-worker.js <socket-path>
6
6
 
7
+ // Early diagnostic — writes directly to fd 2 to ensure output even if pipes close fast
8
+ try { require("fs").writeSync(2, "[sdk-worker] BOOT pid=" + process.pid + " uid=" + (typeof process.getuid === "function" ? process.getuid() : "?") + " argv=" + process.argv.slice(1).join(" ") + "\n"); } catch (e) {}
9
+
7
10
  var net = require("net");
8
11
  var crypto = require("crypto");
9
12
  var path = require("path");
@@ -217,6 +220,31 @@ async function handleQueryStart(msg) {
217
220
  messageQueue.push(msg.prompt);
218
221
  }
219
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
+
220
248
  // Build query options (callbacks are local, everything else from daemon)
221
249
  var options = msg.options || {};
222
250
  options.abortController = abortController;
@@ -391,6 +419,9 @@ async function handleWarmup(msg) {
391
419
 
392
420
  // --- Cleanup ---
393
421
  function cleanup() {
422
+ if (_keepAlive) {
423
+ try { clearInterval(_keepAlive); } catch (e) {}
424
+ }
394
425
  if (abortController) {
395
426
  try { abortController.abort(); } catch (e) {}
396
427
  }
@@ -402,8 +433,14 @@ function cleanup() {
402
433
  }
403
434
  }
404
435
 
436
+ // Keep event loop alive — without this, Node may exit if the socket handle
437
+ // gets unreferenced (observed on Linux with uid/gid spawn)
438
+ var _keepAlive = setInterval(function() {}, 30000);
439
+
405
440
  // --- Connect to daemon socket ---
441
+ try { require("fs").writeSync(2, "[sdk-worker] Connecting to socket: " + socketPath + "\n"); } catch (e) {}
406
442
  conn = net.connect(socketPath, function() {
443
+ try { require("fs").writeSync(2, "[sdk-worker] Connected, sending ready\n"); } catch (e) {}
407
444
  sendToDaemon({ type: "ready" });
408
445
  });
409
446
 
@@ -429,18 +466,20 @@ conn.on("error", function(err) {
429
466
  });
430
467
 
431
468
  conn.on("close", function() {
432
- console.log("[sdk-worker] Socket closed, shutting down");
469
+ try { require("fs").writeSync(2, "[sdk-worker] EXIT REASON: socket closed\n"); } catch (e) {}
433
470
  cleanup();
434
471
  process.exit(0);
435
472
  });
436
473
 
437
474
  // Handle process signals
438
475
  process.on("SIGTERM", function() {
476
+ try { require("fs").writeSync(2, "[sdk-worker] EXIT REASON: SIGTERM\n"); } catch (e) {}
439
477
  cleanup();
440
478
  process.exit(0);
441
479
  });
442
480
 
443
481
  process.on("SIGINT", function() {
482
+ try { require("fs").writeSync(2, "[sdk-worker] EXIT REASON: SIGINT\n"); } catch (e) {}
444
483
  cleanup();
445
484
  process.exit(0);
446
485
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.20.1-beta.9",
3
+ "version": "2.21.0-beta.2",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",