clay-server 2.20.0 → 2.20.1-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/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  <p align="center"><img src="media/hero.png" alt="Clay workspace" /></p>
10
10
 
11
- AI teammates who remember your decisions, challenge your thinking, and grow with your codebase. Runs on your machine.
11
+ A team workspace built on Claude Code. AI teammates who remember your decisions, challenge your thinking, and grow with your codebase. Runs on your machine.
12
12
 
13
13
  ```bash
14
14
  npx clay-server
package/bin/cli.js CHANGED
@@ -36,7 +36,7 @@ var crypto = require("crypto");
36
36
  var { loadConfig, saveConfig, configPath, socketPath, logPath, ensureConfigDir, isDaemonAlive, isDaemonAliveAsync, generateSlug, clearStaleConfig, loadClayrc, saveClayrc, readCrashInfo, REAL_HOME } = require("../lib/config");
37
37
  var { sendIPCCommand } = require("../lib/ipc");
38
38
  var { generateAuthToken } = require("../lib/server");
39
- var { enableMultiUser, disableMultiUser, hasAdmin, isMultiUser } = require("../lib/users");
39
+ var { enableMultiUser, disableMultiUser, hasAdmin, isMultiUser, getSetupCode } = require("../lib/users");
40
40
 
41
41
  function openUrl(url) {
42
42
  try {
@@ -1417,16 +1417,15 @@ function setup(callback) {
1417
1417
  }
1418
1418
  var isRoot = typeof process.getuid === "function" && process.getuid() === 0;
1419
1419
  if (!isRoot) {
1420
- // Save config so sudo clay can pick it up
1421
- var partialConfig = {
1422
- port: port,
1423
- host: host,
1424
- mode: "multi",
1425
- osUsers: true,
1426
- setupCompleted: true,
1427
- dangerouslySkipPermissions: dangerouslySkipPermissions,
1428
- };
1429
- saveConfig(partialConfig);
1420
+ // Merge into existing config (preserve projects, TLS, etc.)
1421
+ var existingCfg = loadConfig() || {};
1422
+ existingCfg.port = port;
1423
+ existingCfg.host = host;
1424
+ existingCfg.mode = "multi";
1425
+ existingCfg.osUsers = true;
1426
+ existingCfg.setupCompleted = true;
1427
+ if (dangerouslySkipPermissions) existingCfg.dangerouslySkipPermissions = true;
1428
+ saveConfig(existingCfg);
1430
1429
  log(sym.bar);
1431
1430
  log(sym.warn + " " + a.yellow + "OS user isolation requires root." + a.reset);
1432
1431
  log(sym.bar + " Run:");
@@ -1999,6 +1998,7 @@ function showMainMenu(config, ip, setupCode) {
1999
1998
  parts.push(a.reset + a.yellow + a.bold + totalAwaiting + a.reset + a.yellow + " awaiting" + a.reset + a.dim);
2000
1999
  }
2001
2000
  log(" " + a.dim + parts.join(a.reset + a.dim + " · ") + a.reset);
2001
+ log(" " + a.dim + "~/.clay → " + path.join(REAL_HOME, ".clay") + a.reset);
2002
2002
  log(" Press " + a.bold + "o" + a.reset + " to open in browser");
2003
2003
  log("");
2004
2004
 
@@ -2009,8 +2009,10 @@ function showMainMenu(config, ip, setupCode) {
2009
2009
  log("");
2010
2010
  }
2011
2011
 
2012
- if (setupCode) {
2013
- log(" " + a.yellow + sym.warn + " Setup code: " + a.bold + setupCode + a.reset);
2012
+ // Always show setup code if one exists (persists until admin is created)
2013
+ var displayCode = setupCode || getSetupCode();
2014
+ if (displayCode) {
2015
+ log(" " + a.yellow + sym.warn + " Setup code: " + a.bold + displayCode + a.reset);
2014
2016
  log(" " + a.dim + "Open Clay in your browser and enter this code to create the admin account." + a.reset);
2015
2017
  log("");
2016
2018
  }
@@ -2373,6 +2375,9 @@ function showSettingsMenu(config, ip) {
2373
2375
  } else {
2374
2376
  items.push({ label: "Enable multi-user mode", value: "multi_user" });
2375
2377
  }
2378
+ if (muEnabled) {
2379
+ items.push({ label: "Show setup code", value: "show_setup_code" });
2380
+ }
2376
2381
  if (muEnabled && hasAdmin()) {
2377
2382
  items.push({ label: "Recover admin password", value: "recover_admin" });
2378
2383
  }
@@ -2478,7 +2483,9 @@ function showSettingsMenu(config, ip) {
2478
2483
  }
2479
2484
  if (process.getuid() !== 0) {
2480
2485
  log(sym.bar);
2481
- log(sym.bar + " " + a.red + "Requires running as root." + a.reset);
2486
+ log(sym.bar + " " + a.red + sym.warn + " OS user isolation requires root." + a.reset);
2487
+ log(sym.bar + " " + a.dim + "Shut down this server, then restart with:" + a.reset);
2488
+ log(sym.bar + " " + a.bold + "sudo npx clay-server" + a.reset);
2482
2489
  log(sym.bar);
2483
2490
  promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
2484
2491
  showSettingsMenu(config, ip);
@@ -2527,33 +2534,95 @@ function showSettingsMenu(config, ip) {
2527
2534
  log(sym.bar);
2528
2535
  log(sym.bar + " " + a.dim + "Then try enabling OS user isolation again." + a.reset);
2529
2536
  log(sym.bar);
2530
- } else if (res.ok) {
2531
- config.osUsers = true;
2537
+ showSettingsMenu(config, ip);
2538
+ return;
2539
+ } else if (res.error) {
2532
2540
  log(sym.bar);
2533
- log(sym.done + " " + a.green + "OS-level user isolation enabled." + a.reset);
2534
- if (res.provisioning) {
2535
- var p = res.provisioning;
2536
- if (p.provisioned.length > 0) {
2537
- log(sym.bar);
2538
- log(sym.bar + " " + a.green + "Provisioned " + p.provisioned.length + " Linux account(s):" + a.reset);
2539
- for (var pi = 0; pi < p.provisioned.length; pi++) {
2540
- log(sym.bar + " " + a.dim + p.provisioned[pi].username + " -> " + p.provisioned[pi].linuxUser + a.reset);
2541
- }
2541
+ log(sym.bar + " " + a.red + sym.warn + " Failed to enable OS users: " + res.error + a.reset);
2542
+ log(sym.bar);
2543
+ showSettingsMenu(config, ip);
2544
+ return;
2545
+ } else if (!res.ok) {
2546
+ log(sym.bar);
2547
+ log(sym.bar + " " + a.red + sym.warn + " Unexpected response from daemon." + a.reset);
2548
+ log(sym.bar + " " + a.dim + JSON.stringify(res) + a.reset);
2549
+ log(sym.bar);
2550
+ showSettingsMenu(config, ip);
2551
+ return;
2552
+ }
2553
+ // Daemon saved the flag. Now provision from CLI with live progress.
2554
+ config.osUsers = true;
2555
+ log(sym.bar);
2556
+ log(sym.done + " " + a.green + "OS-level user isolation enabled." + a.reset);
2557
+ log(sym.bar);
2558
+
2559
+ // Provision Linux accounts from CLI (we have root + terminal)
2560
+ var osUsersLib = require("../lib/os-users");
2561
+ var usersLib = require("../lib/users");
2562
+
2563
+ try { osUsersLib.ensureProjectsDir(); } catch (e) {
2564
+ log(sym.bar + " " + a.yellow + sym.warn + " Failed to create projects dir: " + e.message + a.reset);
2565
+ }
2566
+
2567
+ var allUsers = usersLib.getAllUsers();
2568
+ if (allUsers.length === 0) {
2569
+ log(sym.bar + " " + a.dim + "No users to provision yet. Accounts will be created when users register." + a.reset);
2570
+ } else {
2571
+ log(sym.bar + " " + a.dim + "Provisioning " + allUsers.length + " user(s)..." + a.reset);
2572
+ for (var ui = 0; ui < allUsers.length; ui++) {
2573
+ var usr = allUsers[ui];
2574
+ if (usr.linuxUser && osUsersLib.linuxUserExists(usr.linuxUser)) {
2575
+ log(sym.bar + " " + a.dim + sym.done + " " + usr.username + " -> " + usr.linuxUser + " (exists)" + a.reset);
2576
+ continue;
2542
2577
  }
2543
- if (p.skipped.length > 0) {
2544
- log(sym.bar + " " + a.dim + p.skipped.length + " user(s) already mapped." + a.reset);
2578
+ log(sym.bar + " " + a.dim + "Creating Linux account for " + usr.username + "..." + a.reset);
2579
+ var provision = osUsersLib.provisionLinuxUser(usr.username);
2580
+ if (provision.ok) {
2581
+ usersLib.updateLinuxUser(usr.id, provision.linuxUser);
2582
+ log(sym.bar + " " + a.green + sym.done + " " + usr.username + " -> " + provision.linuxUser + a.reset);
2583
+ } else {
2584
+ log(sym.bar + " " + a.red + sym.warn + " " + usr.username + ": " + (provision.error || "unknown error") + a.reset);
2545
2585
  }
2546
- if (p.errors.length > 0) {
2547
- log(sym.bar);
2548
- log(sym.bar + " " + a.red + p.errors.length + " user(s) failed to provision:" + a.reset);
2549
- for (var ei = 0; ei < p.errors.length; ei++) {
2550
- log(sym.bar + " " + a.red + p.errors[ei].username + ": " + p.errors[ei].error + a.reset);
2586
+ }
2587
+ }
2588
+
2589
+ // Set up ACLs for existing projects
2590
+ var cfg = loadConfig() || {};
2591
+ var cfgProjects = cfg.projects || [];
2592
+ if (cfgProjects.length > 0) {
2593
+ log(sym.bar);
2594
+ log(sym.bar + " " + a.dim + "Setting ACLs for " + cfgProjects.length + " project(s)..." + a.reset);
2595
+ for (var pi = 0; pi < cfgProjects.length; pi++) {
2596
+ var proj = cfgProjects[pi];
2597
+ if (osUsersLib.isHomeDirectory(proj.path)) {
2598
+ log(sym.bar + " " + a.dim + "~ " + (proj.slug || proj.path) + " (home dir, skipped)" + a.reset);
2599
+ continue;
2600
+ }
2601
+ try {
2602
+ if (proj.visibility === "public") {
2603
+ osUsersLib.grantAllUsersAccess(proj.path, usersLib);
2604
+ }
2605
+ if (proj.ownerId) {
2606
+ var ownerUser = usersLib.findUserById(proj.ownerId);
2607
+ if (ownerUser && ownerUser.linuxUser) {
2608
+ osUsersLib.grantProjectAccess(proj.path, ownerUser.linuxUser);
2609
+ }
2551
2610
  }
2611
+ log(sym.bar + " " + a.dim + sym.done + " " + (proj.slug || proj.path) + a.reset);
2612
+ } catch (aclErr) {
2613
+ log(sym.bar + " " + a.yellow + sym.warn + " " + (proj.slug || proj.path) + ": " + aclErr.message + a.reset);
2552
2614
  }
2553
2615
  }
2554
- log(sym.bar + " " + a.dim + "Restart the daemon for changes to take full effect." + a.reset);
2555
- log(sym.bar);
2556
2616
  }
2617
+
2618
+ log(sym.bar);
2619
+ log(sym.bar + " " + a.dim + "Restart the daemon for full effect." + a.reset);
2620
+ log(sym.bar);
2621
+ showSettingsMenu(config, ip);
2622
+ }).catch(function (err) {
2623
+ log(sym.bar);
2624
+ log(sym.bar + " " + a.red + sym.warn + " IPC error: " + (err.message || err) + a.reset);
2625
+ log(sym.bar);
2557
2626
  showSettingsMenu(config, ip);
2558
2627
  });
2559
2628
  } else {
@@ -2603,33 +2672,48 @@ function showSettingsMenu(config, ip) {
2603
2672
  { label: "Cancel", value: "cancel" },
2604
2673
  ], function (confirmChoice) {
2605
2674
  if (confirmChoice === "confirm") {
2606
- // Clear setupCompleted so setup() runs fresh
2675
+ // Save old PID before clearing, so we can force-kill if needed
2607
2676
  var cfg = loadConfig() || {};
2677
+ var oldPid = cfg.pid;
2678
+ // Clear setupCompleted so setup() runs fresh
2608
2679
  delete cfg.setupCompleted;
2609
2680
  delete cfg.mode;
2610
2681
  cfg.pid = null;
2611
2682
  saveConfig(cfg);
2612
- // Shut down the daemon
2613
- sendIPCCommand(socketPath(), { cmd: "shutdown" }).then(function () {
2614
- clearStaleConfig();
2615
- // Run the setup wizard, then fork a new daemon
2616
- setup(function (mode, keepAwake, wantOsUsers) {
2617
- var rc = loadClayrc();
2618
- var restorable = (rc.recentProjects || []).filter(function (p) {
2619
- return p.path !== cwd && fs.existsSync(p.path);
2683
+
2684
+ // Helper: wait for port to be free, force-kill if needed
2685
+ function waitForPortFree(cb) {
2686
+ var attempts = 0;
2687
+ var maxAttempts = 12; // 6 seconds total
2688
+ function check() {
2689
+ isPortFree(port).then(function (free) {
2690
+ if (free) return cb();
2691
+ attempts++;
2692
+ if (attempts >= maxAttempts) {
2693
+ // Port still busy, force-kill old daemon
2694
+ if (oldPid) {
2695
+ try { process.kill(oldPid, "SIGKILL"); } catch (e) {}
2696
+ }
2697
+ // Wait a bit more after SIGKILL
2698
+ setTimeout(function () {
2699
+ isPortFree(port).then(function (free2) {
2700
+ if (!free2) {
2701
+ log(sym.warn + " " + a.yellow + "Port " + port + " still in use. Kill the process manually:" + a.reset);
2702
+ log(sym.bar + " " + a.bold + "lsof -ti:" + port + " | xargs kill -9" + a.reset);
2703
+ }
2704
+ cb();
2705
+ });
2706
+ }, 1000);
2707
+ return;
2708
+ }
2709
+ setTimeout(check, 500);
2620
2710
  });
2621
- if (restorable.length > 0) {
2622
- promptRestoreProjects(restorable, function (selected) {
2623
- forkDaemon(mode, keepAwake, selected, false, wantOsUsers);
2624
- });
2625
- } else {
2626
- log(sym.bar);
2627
- log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
2628
- log("");
2629
- forkDaemon(mode, keepAwake, undefined, true, wantOsUsers);
2630
- }
2631
- });
2632
- }).catch(function () {
2711
+ }
2712
+ check();
2713
+ }
2714
+
2715
+ // Helper: run setup wizard after daemon is dead
2716
+ function proceedWithSetup() {
2633
2717
  clearStaleConfig();
2634
2718
  setup(function (mode, keepAwake, wantOsUsers) {
2635
2719
  var rc = loadClayrc();
@@ -2647,6 +2731,17 @@ function showSettingsMenu(config, ip) {
2647
2731
  forkDaemon(mode, keepAwake, undefined, true, wantOsUsers);
2648
2732
  }
2649
2733
  });
2734
+ }
2735
+
2736
+ // Shut down the daemon, then wait for port to be free
2737
+ sendIPCCommand(socketPath(), { cmd: "shutdown" }).then(function () {
2738
+ waitForPortFree(proceedWithSetup);
2739
+ }).catch(function () {
2740
+ // IPC failed, daemon may be unresponsive. Try SIGTERM, then wait.
2741
+ if (oldPid) {
2742
+ try { process.kill(oldPid, "SIGTERM"); } catch (e) {}
2743
+ }
2744
+ waitForPortFree(proceedWithSetup);
2650
2745
  });
2651
2746
  } else {
2652
2747
  showSettingsMenu(config, ip);
@@ -2654,6 +2749,26 @@ function showSettingsMenu(config, ip) {
2654
2749
  });
2655
2750
  break;
2656
2751
 
2752
+ case "show_setup_code":
2753
+ // getSetupCode() auto-generates if multi-user is on and no code exists
2754
+ var currentCode = getSetupCode();
2755
+ log(sym.bar);
2756
+ if (currentCode) {
2757
+ log(sym.bar + " " + a.yellow + sym.warn + " Setup code: " + a.bold + currentCode + a.reset);
2758
+ if (hasAdmin()) {
2759
+ log(sym.bar + " " + a.dim + "Admin account exists. This code is for adding the next admin." + a.reset);
2760
+ } else {
2761
+ log(sym.bar + " " + a.dim + "Enter this code in the browser to create the admin account." + a.reset);
2762
+ }
2763
+ } else {
2764
+ log(sym.bar + " " + a.dim + "Multi-user mode is not enabled." + a.reset);
2765
+ }
2766
+ log(sym.bar);
2767
+ promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
2768
+ showSettingsMenu(config, ip);
2769
+ });
2770
+ break;
2771
+
2657
2772
  case "logs":
2658
2773
  console.clear();
2659
2774
  log(a.bold + "Daemon logs" + a.reset + " " + a.dim + "(" + logPath() + ")" + a.reset);
package/lib/config.js CHANGED
@@ -6,22 +6,28 @@ var net = require("net");
6
6
  // When running under sudo, resolve the real user's home directory
7
7
  // so that ~/.clay/ points to the original user's data, not /root/.clay/
8
8
  function getRealHome() {
9
- if (process.env.SUDO_USER) {
9
+ var sudoUser = process.env.SUDO_USER;
10
+ if (sudoUser && sudoUser !== "root") {
11
+ // 1. Try getent passwd (works on most Linux, may fail with some NSS configs)
10
12
  try {
11
13
  var entry = require("child_process")
12
- .execSync("getent passwd " + process.env.SUDO_USER, { encoding: "utf8" })
14
+ .execSync("getent passwd " + sudoUser, { encoding: "utf8", timeout: 3000 })
13
15
  .trim();
14
16
  var home = entry.split(":")[5];
15
- if (home) return home;
16
- } catch (e) {
17
- // getent failed (e.g. macOS), try user home dir from ~SUDO_USER
18
- try {
19
- var home = require("child_process")
20
- .execSync("eval echo ~" + process.env.SUDO_USER, { encoding: "utf8" })
21
- .trim();
22
- if (home && home !== "~" + process.env.SUDO_USER) return home;
23
- } catch (e2) {}
24
- }
17
+ if (home && fs.existsSync(home)) return home;
18
+ } catch (e) {}
19
+ // 2. Try shell expansion of ~USER
20
+ try {
21
+ var home = require("child_process")
22
+ .execSync("eval echo ~" + sudoUser, { encoding: "utf8", timeout: 3000 })
23
+ .trim();
24
+ if (home && home !== "~" + sudoUser && fs.existsSync(home)) return home;
25
+ } catch (e2) {}
26
+ // 3. Direct path fallback (GCE, cloud VMs)
27
+ var directHome = "/home/" + sudoUser;
28
+ if (fs.existsSync(directHome)) return directHome;
29
+ // 4. SUDO_USER's original HOME (some sudo configs preserve it)
30
+ if (process.env.SUDO_HOME && fs.existsSync(process.env.SUDO_HOME)) return process.env.SUDO_HOME;
25
31
  }
26
32
  return os.homedir();
27
33
  }
@@ -29,7 +35,11 @@ function getRealHome() {
29
35
  var REAL_HOME = getRealHome();
30
36
 
31
37
  // v3: ~/.clay/ (v2 was ~/.claude-relay/, v1 was {cwd}/.claude-relay/)
32
- var CLAY_HOME = process.env.CLAY_HOME || path.join(REAL_HOME, ".clay");
38
+ // If CLAY_CONFIG is set (daemon mode), derive CLAY_HOME from it so that
39
+ // daemon.json, users.json, sessions/, etc. all live in the same directory.
40
+ var CLAY_HOME = process.env.CLAY_HOME
41
+ || (process.env.CLAY_CONFIG ? path.dirname(process.env.CLAY_CONFIG) : null)
42
+ || path.join(REAL_HOME, ".clay");
33
43
  var LEGACY_HOME = path.join(REAL_HOME, ".claude-relay");
34
44
 
35
45
  // Auto-migrate v2 -> v3: rename ~/.claude-relay/ to ~/.clay/ (once, before anything reads)
package/lib/daemon.js CHANGED
@@ -33,6 +33,7 @@ var usersModule = require("./users");
33
33
  var { scanWorktrees, createWorktree, removeWorktree, isWorktree } = require("./worktree");
34
34
  var mates = require("./mates");
35
35
 
36
+ var daemonVersion = require("../package.json").version;
36
37
  var configFile = process.env.CLAY_CONFIG || process.env.CLAUDE_RELAY_CONFIG || require("./config").configPath();
37
38
  var config;
38
39
 
@@ -42,6 +43,12 @@ try {
42
43
  console.error("[daemon] Failed to read config:", e.message);
43
44
  process.exit(1);
44
45
  }
46
+ console.log("[daemon] v" + daemonVersion + " PID " + process.pid + " config " + configFile);
47
+
48
+ console.log("[daemon] Config: " + configFile);
49
+ console.log("[daemon] Users: " + usersModule.USERS_FILE);
50
+ if (process.env.SUDO_USER) console.log("[daemon] SUDO_USER: " + process.env.SUDO_USER);
51
+ console.log("[daemon] UID: " + (typeof process.getuid === "function" ? process.getuid() : "N/A"));
45
52
 
46
53
  // --- OS users mode: check required system dependencies ---
47
54
  if (config.osUsers) {
@@ -1020,6 +1027,7 @@ if (existingConfig && existingConfig.pid && existingConfig.pid !== process.pid)
1020
1027
  }
1021
1028
  }
1022
1029
  var ipc = createIPCServer(socketPath(), function (msg) {
1030
+ console.log("[daemon] IPC:", msg.cmd);
1023
1031
  switch (msg.cmd) {
1024
1032
  case "add_project": {
1025
1033
  if (!msg.path) return { ok: false, error: "missing path" };
@@ -1129,45 +1137,8 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1129
1137
  config.osUsers = enableOsUsers;
1130
1138
  saveConfig(config);
1131
1139
  console.log("[daemon] OS users:", enableOsUsers);
1132
- if (enableOsUsers) {
1133
- // Ensure shared projects directory exists
1134
- try { ensureProjectsDir(); } catch (e) {
1135
- console.error("[daemon] Failed to create projects dir:", e.message);
1136
- }
1137
- // Auto-provision Linux accounts for all existing users
1138
- var provisionResult = provisionAllUsers(usersModule);
1139
- console.log("[daemon] Provisioning result: " +
1140
- provisionResult.provisioned.length + " provisioned, " +
1141
- provisionResult.skipped.length + " skipped, " +
1142
- provisionResult.errors.length + " errors");
1143
- // Set up ACLs for all existing projects
1144
- for (var pi = 0; pi < config.projects.length; pi++) {
1145
- var proj = config.projects[pi];
1146
- var projPath = proj.path;
1147
- var projVisibility = proj.visibility || "public";
1148
- // Grant ACL to project owner
1149
- if (proj.ownerId) {
1150
- var ownerUser = usersModule.findUserById(proj.ownerId);
1151
- if (ownerUser && ownerUser.linuxUser) {
1152
- grantProjectAccess(projPath, ownerUser.linuxUser);
1153
- }
1154
- }
1155
- // Public projects: grant ACL to all users
1156
- if (projVisibility === "public") {
1157
- grantAllUsersAccess(projPath, usersModule);
1158
- } else {
1159
- // Private projects: grant ACL to allowedUsers
1160
- var projAllowed = proj.allowedUsers || [];
1161
- for (var ai = 0; ai < projAllowed.length; ai++) {
1162
- var allowedUser = usersModule.findUserById(projAllowed[ai]);
1163
- if (allowedUser && allowedUser.linuxUser) {
1164
- grantProjectAccess(projPath, allowedUser.linuxUser);
1165
- }
1166
- }
1167
- }
1168
- }
1169
- return { ok: true, provisioning: provisionResult };
1170
- }
1140
+ // Provisioning is handled by CLI (which has terminal access for progress).
1141
+ // Daemon only saves the flag. On next restart, daemon will pick it up.
1171
1142
  return { ok: true };
1172
1143
  }
1173
1144
 
@@ -1292,37 +1263,49 @@ function startListening() {
1292
1263
  config.pid = process.pid;
1293
1264
  saveConfig(config);
1294
1265
 
1295
- // Auto-provision Linux accounts on startup if OS users mode is enabled
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.
1296
1268
  if (config.osUsers) {
1297
- try { ensureProjectsDir(); } catch (e) {}
1298
- var provResult = provisionAllUsers(usersModule);
1299
- if (provResult.provisioned.length > 0) {
1300
- console.log("[daemon] Auto-provisioned " + provResult.provisioned.length + " Linux account(s) on startup");
1301
- }
1302
- if (provResult.errors.length > 0) {
1303
- console.error("[daemon] Failed to provision " + provResult.errors.length + " account(s)");
1304
- }
1305
- // Set up ACLs for all existing projects on startup
1306
- for (var pi = 0; pi < config.projects.length; pi++) {
1307
- var proj = config.projects[pi];
1308
- if (proj.ownerId) {
1309
- var ownerUser = usersModule.findUserById(proj.ownerId);
1310
- if (ownerUser && ownerUser.linuxUser) {
1311
- grantProjectAccess(proj.path, ownerUser.linuxUser);
1269
+ setTimeout(function () {
1270
+ try { ensureProjectsDir(); } catch (e) {}
1271
+ try {
1272
+ var provResult = provisionAllUsers(usersModule);
1273
+ if (provResult.provisioned.length > 0) {
1274
+ console.log("[daemon] Auto-provisioned " + provResult.provisioned.length + " Linux account(s) on startup");
1312
1275
  }
1276
+ if (provResult.errors.length > 0) {
1277
+ console.error("[daemon] Failed to provision " + provResult.errors.length + " account(s)");
1278
+ }
1279
+ } catch (provErr) {
1280
+ console.error("[daemon] Startup provisioning error:", provErr.message);
1313
1281
  }
1314
- if ((proj.visibility || "public") === "public") {
1315
- grantAllUsersAccess(proj.path, usersModule);
1316
- } else {
1317
- var projAllowed = proj.allowedUsers || [];
1318
- for (var ai = 0; ai < projAllowed.length; ai++) {
1319
- var allowedUser = usersModule.findUserById(projAllowed[ai]);
1320
- if (allowedUser && allowedUser.linuxUser) {
1321
- grantProjectAccess(proj.path, allowedUser.linuxUser);
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
+ }
1322
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);
1323
1305
  }
1324
1306
  }
1325
- }
1307
+ console.log("[daemon] Startup OS users provisioning complete.");
1308
+ }, 100);
1326
1309
  }
1327
1310
 
1328
1311
  // Check for crash info from a previous crash and notify clients
package/lib/ipc.js CHANGED
@@ -74,7 +74,8 @@ function createIPCServer(sockPath, handler) {
74
74
  * Send a command to the daemon IPC server and wait for response.
75
75
  * Returns a Promise resolving to the parsed response.
76
76
  */
77
- function sendIPCCommand(sockPath, message) {
77
+ function sendIPCCommand(sockPath, message, timeout) {
78
+ var timeoutMs = timeout || 3000;
78
79
  return new Promise(function (resolve) {
79
80
  var client = net.connect(sockPath);
80
81
  var buffer = "";
@@ -86,7 +87,7 @@ function sendIPCCommand(sockPath, message) {
86
87
  client.destroy();
87
88
  resolve({ ok: false, error: "timeout" });
88
89
  }
89
- }, 3000);
90
+ }, timeoutMs);
90
91
 
91
92
  client.on("connect", function () {
92
93
  client.write(JSON.stringify(message) + "\n");
package/lib/os-users.js CHANGED
@@ -157,11 +157,30 @@ function checkAclSupport() {
157
157
  }
158
158
  }
159
159
 
160
+ /**
161
+ * Check if a path is a user's home directory (e.g. /home/chad, /root).
162
+ * Running recursive setfacl on a home dir is dangerous and slow.
163
+ */
164
+ function isHomeDirectory(dirPath) {
165
+ var resolved = path.resolve(dirPath);
166
+ // /root
167
+ if (resolved === "/root") return true;
168
+ // /home/username (exactly two levels)
169
+ var parts = resolved.split("/");
170
+ if (parts.length === 3 && parts[0] === "" && parts[1] === "home" && parts[2]) return true;
171
+ return false;
172
+ }
173
+
160
174
  /**
161
175
  * Grant a Linux user ACL access (rwX) to a project directory.
162
176
  * Uses setfacl to add recursive + default ACL entries.
177
+ * Skips home directories to avoid slow recursive ACL on large trees.
163
178
  */
164
179
  function grantProjectAccess(projectPath, linuxUser) {
180
+ if (isHomeDirectory(projectPath)) {
181
+ console.log("[os-users] Skipping ACL for home directory: " + projectPath);
182
+ return;
183
+ }
165
184
  try {
166
185
  // Recursive ACL for existing files
167
186
  execSync("setfacl -R -m u:" + linuxUser + ":rwX " + JSON.stringify(projectPath), {
@@ -190,6 +209,10 @@ function grantProjectAccess(projectPath, linuxUser) {
190
209
  * Revoke a Linux user's ACL access from a project directory.
191
210
  */
192
211
  function revokeProjectAccess(projectPath, linuxUser) {
212
+ if (isHomeDirectory(projectPath)) {
213
+ console.log("[os-users] Skipping ACL revoke for home directory: " + projectPath);
214
+ return;
215
+ }
193
216
  try {
194
217
  execSync("setfacl -R -x u:" + linuxUser + " " + JSON.stringify(projectPath), {
195
218
  encoding: "utf8",
@@ -424,4 +447,5 @@ module.exports = {
424
447
  installClaudeCli: installClaudeCli,
425
448
  deactivateLinuxUser: deactivateLinuxUser,
426
449
  ensureProjectsDir: ensureProjectsDir,
450
+ isHomeDirectory: isHomeDirectory,
427
451
  };
package/lib/users.js CHANGED
@@ -115,7 +115,15 @@ function generateSetupCode() {
115
115
 
116
116
  function getSetupCode() {
117
117
  var data = loadUsers();
118
- return data.setupCode || null;
118
+ if (data.setupCode) return data.setupCode;
119
+ // Defensive: if multi-user is on, no admin, and no code, auto-generate one
120
+ if (data.multiUser && !findAdmin(data)) {
121
+ var code = generateSetupCode();
122
+ data.setupCode = code;
123
+ saveUsers(data);
124
+ return code;
125
+ }
126
+ return null;
119
127
  }
120
128
 
121
129
  function clearSetupCode() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.20.0",
3
+ "version": "2.20.1-beta.10",
4
4
  "description": "Web UI for Claude Code. Any device. Push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",