clay-server 2.11.0-beta.4 → 2.11.0-beta.5

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
@@ -62,6 +62,7 @@ var headlessMode = false;
62
62
  var watchMode = false;
63
63
  var host = null;
64
64
  var multiUserMode = false;
65
+ var osUsersMode = false;
65
66
 
66
67
  for (var i = 0; i < args.length; i++) {
67
68
  if (args[i] === "-p" || args[i] === "--port") {
@@ -108,6 +109,8 @@ for (var i = 0; i < args.length; i++) {
108
109
  dangerouslySkipPermissions = true;
109
110
  } else if (args[i] === "--multi-user") {
110
111
  multiUserMode = true;
112
+ } else if (args[i] === "--os-users") {
113
+ osUsersMode = true;
111
114
  } else if (args[i] === "-h" || args[i] === "--help") {
112
115
  console.log("Usage: clay-server [-p|--port <port>] [--host <address>] [--no-https] [--no-update] [--debug] [-y|--yes] [--pin <pin>] [--shutdown] [--restart]");
113
116
  console.log(" clay-server --add <path> Add a project to the running daemon");
@@ -129,6 +132,7 @@ for (var i = 0; i < args.length; i++) {
129
132
  console.log(" --list List all registered projects");
130
133
  console.log(" --headless Start daemon and exit immediately (implies --yes)");
131
134
  console.log(" --multi-user Enable multi-user mode (generates setup code)");
135
+ console.log(" --os-users Enable OS-level user isolation (Linux only, requires root + --multi-user)");
132
136
  console.log(" --dangerously-skip-permissions");
133
137
  console.log(" Bypass all permission prompts");
134
138
  process.exit(0);
@@ -295,6 +299,30 @@ if (multiUserMode) {
295
299
  process.exit(0);
296
300
  }
297
301
 
302
+ // --- Handle --os-users validation ---
303
+ if (osUsersMode) {
304
+ if (process.platform !== "linux") {
305
+ console.error("\x1b[31mError: --os-users requires Linux.\x1b[0m");
306
+ console.error("OS-level user isolation depends on setfacl, getent, and uid/gid process spawning.");
307
+ process.exit(1);
308
+ }
309
+ if (typeof process.getuid === "function" && process.getuid() !== 0) {
310
+ console.error("\x1b[31mError: --os-users requires running as root.\x1b[0m");
311
+ console.error("The daemon must run as root to spawn worker processes as different users.");
312
+ console.error("Use: sudo clay --multi-user --os-users");
313
+ process.exit(1);
314
+ }
315
+ if (!isMultiUser()) {
316
+ console.error("\x1b[31mError: --os-users requires --multi-user mode.\x1b[0m");
317
+ console.error("Enable multi-user mode first: clay --multi-user");
318
+ process.exit(1);
319
+ }
320
+ console.log("\x1b[32mOS-level user isolation enabled.\x1b[0m");
321
+ console.log("Worker processes will run as mapped Linux users.");
322
+ console.log("Linux accounts will be auto-provisioned for existing users on startup.");
323
+ console.log("");
324
+ }
325
+
298
326
  var cwd = process.cwd();
299
327
 
300
328
  // --- ANSI helpers ---
@@ -460,6 +488,7 @@ async function restartDaemonFromConfig() {
460
488
  debug: lastConfig.debug || false,
461
489
  keepAwake: lastConfig.keepAwake || false,
462
490
  dangerouslySkipPermissions: lastConfig.dangerouslySkipPermissions || false,
491
+ osUsers: lastConfig.osUsers || false,
463
492
  projects: (lastConfig.projects || []).filter(function (p) {
464
493
  return fs.existsSync(p.path);
465
494
  }),
@@ -1408,6 +1437,7 @@ async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
1408
1437
  debug: debugMode,
1409
1438
  keepAwake: keepAwake,
1410
1439
  dangerouslySkipPermissions: dangerouslySkipPermissions,
1440
+ osUsers: osUsersMode,
1411
1441
  projects: allProjects,
1412
1442
  };
1413
1443
 
@@ -2268,6 +2298,7 @@ function showSetupGuide(config, ip, goBack) {
2268
2298
  function showSettingsMenu(config, ip) {
2269
2299
  sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (status) {
2270
2300
  var isAwake = status && status.keepAwake;
2301
+ var isOsUsers = status && status.osUsers;
2271
2302
 
2272
2303
  console.clear();
2273
2304
  printLogo();
@@ -2306,6 +2337,12 @@ function showSettingsMenu(config, ip) {
2306
2337
 
2307
2338
  log(sym.bar + " PIN " + pinStatus);
2308
2339
  log(sym.bar + " Multi-user " + muStatus);
2340
+ var osUsersStatus = isOsUsers
2341
+ ? a.green + "Enabled" + a.reset
2342
+ : a.dim + "Off" + a.reset;
2343
+ if (muEnabled) {
2344
+ log(sym.bar + " OS users " + osUsersStatus);
2345
+ }
2309
2346
  if (process.platform === "darwin") {
2310
2347
  log(sym.bar + " Keep awake " + awakeStatus);
2311
2348
  }
@@ -2324,6 +2361,11 @@ function showSettingsMenu(config, ip) {
2324
2361
  }
2325
2362
  if (muEnabled) {
2326
2363
  items.push({ label: "Disable multi-user mode", value: "disable_multi_user" });
2364
+ if (isOsUsers) {
2365
+ items.push({ label: "Disable OS-level user isolation", value: "disable_os_users" });
2366
+ } else {
2367
+ items.push({ label: "Enable OS-level user isolation", value: "os_users" });
2368
+ }
2327
2369
  } else {
2328
2370
  items.push({ label: "Enable multi-user mode", value: "multi_user" });
2329
2371
  }
@@ -2416,6 +2458,118 @@ function showSettingsMenu(config, ip) {
2416
2458
  });
2417
2459
  break;
2418
2460
 
2461
+ case "os_users":
2462
+ if (process.platform === "win32") {
2463
+ log(sym.bar);
2464
+ log(sym.bar + " " + a.red + "OS-level user isolation is not supported on Windows." + a.reset);
2465
+ log(sym.bar);
2466
+ promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
2467
+ showSettingsMenu(config, ip);
2468
+ });
2469
+ break;
2470
+ }
2471
+ if (process.getuid() !== 0) {
2472
+ log(sym.bar);
2473
+ log(sym.bar + " " + a.red + "Requires running as root." + a.reset);
2474
+ log(sym.bar);
2475
+ promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
2476
+ showSettingsMenu(config, ip);
2477
+ });
2478
+ break;
2479
+ }
2480
+ if (process.platform !== "linux") {
2481
+ log(sym.bar);
2482
+ log(sym.bar + " " + a.red + sym.warn + " OS-level user isolation requires Linux." + a.reset);
2483
+ log(sym.bar + " " + a.dim + "This feature depends on setfacl, getent, and uid/gid process spawning." + a.reset);
2484
+ log(sym.bar + " " + a.dim + "Use Docker or a Linux VM to run Clay with OS user isolation." + a.reset);
2485
+ log(sym.bar);
2486
+ showSettingsMenu(config, ip);
2487
+ return;
2488
+ }
2489
+ log(sym.bar);
2490
+ log(sym.bar + " " + a.yellow + sym.warn + " OS-Level User Isolation" + a.reset);
2491
+ log(sym.bar);
2492
+ log(sym.bar + " " + a.dim + "This feature maps each Clay user to a Linux OS user account." + a.reset);
2493
+ log(sym.bar + " " + a.dim + "The daemon must run as root and will spawn processes (SDK workers," + a.reset);
2494
+ log(sym.bar + " " + a.dim + "terminals, file operations) as the mapped Linux user." + a.reset);
2495
+ log(sym.bar);
2496
+ log(sym.bar + " " + a.dim + "What this means:" + a.reset);
2497
+ log(sym.bar + " " + a.dim + "- Each mapped user uses their own ~/.claude/ credentials" + a.reset);
2498
+ log(sym.bar + " " + a.dim + "- Terminals and file access follow Linux permissions" + a.reset);
2499
+ log(sym.bar + " " + a.dim + "- Linux user accounts are created automatically (clay-username)" + a.reset);
2500
+ log(sym.bar);
2501
+ log(sym.bar + " " + a.dim + "Recommended: Run on a dedicated Clay server or cloud instance," + a.reset);
2502
+ log(sym.bar + " " + a.dim + "not on a personal computer or general-purpose server." + a.reset);
2503
+ log(sym.bar);
2504
+ promptSelect("Select", [
2505
+ { label: "Enable OS-level user isolation", value: "confirm" },
2506
+ { label: "Cancel", value: "cancel" },
2507
+ ], function (confirmChoice) {
2508
+ if (confirmChoice === "confirm") {
2509
+ sendIPCCommand(socketPath(), { cmd: "set_os_users", value: true }).then(function (res) {
2510
+ if (res.ok) {
2511
+ config.osUsers = true;
2512
+ log(sym.bar);
2513
+ log(sym.done + " " + a.green + "OS-level user isolation enabled." + a.reset);
2514
+ if (res.provisioning) {
2515
+ var p = res.provisioning;
2516
+ if (p.provisioned.length > 0) {
2517
+ log(sym.bar);
2518
+ log(sym.bar + " " + a.green + "Provisioned " + p.provisioned.length + " Linux account(s):" + a.reset);
2519
+ for (var pi = 0; pi < p.provisioned.length; pi++) {
2520
+ log(sym.bar + " " + a.dim + p.provisioned[pi].username + " -> " + p.provisioned[pi].linuxUser + a.reset);
2521
+ }
2522
+ }
2523
+ if (p.skipped.length > 0) {
2524
+ log(sym.bar + " " + a.dim + p.skipped.length + " user(s) already mapped." + a.reset);
2525
+ }
2526
+ if (p.errors.length > 0) {
2527
+ log(sym.bar);
2528
+ log(sym.bar + " " + a.red + p.errors.length + " user(s) failed to provision:" + a.reset);
2529
+ for (var ei = 0; ei < p.errors.length; ei++) {
2530
+ log(sym.bar + " " + a.red + p.errors[ei].username + ": " + p.errors[ei].error + a.reset);
2531
+ }
2532
+ }
2533
+ }
2534
+ log(sym.bar + " " + a.dim + "Restart the daemon for changes to take full effect." + a.reset);
2535
+ log(sym.bar);
2536
+ }
2537
+ showSettingsMenu(config, ip);
2538
+ });
2539
+ } else {
2540
+ showSettingsMenu(config, ip);
2541
+ }
2542
+ });
2543
+ break;
2544
+
2545
+ case "disable_os_users":
2546
+ log(sym.bar);
2547
+ log(sym.bar + " " + a.yellow + sym.warn + " Disable OS-level user isolation?" + a.reset);
2548
+ log(sym.bar);
2549
+ log(sym.bar + " " + a.dim + "Processes will no longer be spawned as mapped Linux users." + a.reset);
2550
+ log(sym.bar + " " + a.dim + "User mappings will be preserved and restored if re-enabled." + a.reset);
2551
+ log(sym.bar);
2552
+ promptSelect("Confirm", [
2553
+ { label: "Disable OS-level user isolation", value: "confirm" },
2554
+ { label: "Cancel", value: "cancel" },
2555
+ ], function (confirmChoice) {
2556
+ if (confirmChoice === "confirm") {
2557
+ sendIPCCommand(socketPath(), { cmd: "set_os_users", value: false }).then(function (res) {
2558
+ if (res.ok) {
2559
+ config.osUsers = false;
2560
+ log(sym.bar);
2561
+ log(sym.done + " " + a.green + "OS-level user isolation disabled." + a.reset);
2562
+ log(sym.bar + " " + a.dim + "Restart the daemon for changes to take full effect." + a.reset);
2563
+ log(sym.bar);
2564
+ }
2565
+ showSettingsMenu(config, ip);
2566
+ });
2567
+ } else {
2568
+ showSettingsMenu(config, ip);
2569
+ }
2570
+ });
2571
+ break;
2572
+
2419
2573
  case "logs":
2420
2574
  console.clear();
2421
2575
  log(a.bold + "Daemon logs" + a.reset + " " + a.dim + "(" + logPath() + ")" + a.reset);
package/lib/daemon.js CHANGED
@@ -25,6 +25,8 @@ var path = require("path");
25
25
  var { loadConfig, saveConfig, socketPath, generateSlug, syncClayrc, removeFromClayrc, writeCrashInfo, readCrashInfo, clearCrashInfo, isPidAlive, clearStaleConfig } = require("./config");
26
26
  var { createIPCServer } = require("./ipc");
27
27
  var { createServer, generateAuthToken } = require("./server");
28
+ var { grantProjectAccess, revokeProjectAccess, provisionAllUsers, provisionLinuxUser, grantAllUsersAccess, deactivateLinuxUser, ensureProjectsDir } = require("./os-users");
29
+ var usersModule = require("./users");
28
30
 
29
31
  var configFile = process.env.CLAY_CONFIG || process.env.CLAUDE_RELAY_CONFIG || require("./config").configPath();
30
32
  var config;
@@ -90,6 +92,7 @@ var relay = createServer({
90
92
  port: config.port,
91
93
  debug: config.debug || false,
92
94
  dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
95
+ osUsers: config.osUsers || false,
93
96
  lanHost: lanIp ? lanIp + ":" + config.port : null,
94
97
  onAddProject: function (absPath) {
95
98
  // Check if already registered
@@ -105,6 +108,16 @@ var relay = createServer({
105
108
  saveConfig(config);
106
109
  try { syncClayrc(config.projects); } catch (e) {}
107
110
  console.log("[daemon] Added project (web):", slug, "→", absPath);
111
+ // OS users mode: grant ACL to project owner
112
+ if (config.osUsers) {
113
+ var newProj = config.projects[config.projects.length - 1];
114
+ if (newProj.ownerId) {
115
+ var ownerUser = usersModule.findUserById(newProj.ownerId);
116
+ if (ownerUser && ownerUser.linuxUser) {
117
+ grantProjectAccess(absPath, ownerUser.linuxUser);
118
+ }
119
+ }
120
+ }
108
121
  // Broadcast updated project list to all clients
109
122
  relay.broadcastAll({
110
123
  type: "projects_updated",
@@ -113,6 +126,137 @@ var relay = createServer({
113
126
  });
114
127
  return { ok: true, slug: slug };
115
128
  },
129
+ onCreateProject: function (projectName, wsUser) {
130
+ var os = require("os");
131
+ var { execSync } = require("child_process");
132
+ var baseDir;
133
+ if (config.osUsers) {
134
+ baseDir = "/var/clay/projects";
135
+ } else {
136
+ baseDir = config.projectsDir || path.join(os.homedir(), "clay-projects");
137
+ }
138
+ try { fs.mkdirSync(baseDir, { recursive: true }); } catch (e) {}
139
+ // Generate slug and deduplicate
140
+ var slugs = config.projects.map(function (p) { return p.slug; });
141
+ var slug = generateSlug(path.join(baseDir, projectName), slugs);
142
+ var targetDir = path.join(baseDir, slug);
143
+ try {
144
+ fs.mkdirSync(targetDir, { recursive: true });
145
+ // Run git init
146
+ if (config.osUsers && wsUser) {
147
+ var linuxUser = wsUser.linuxUser;
148
+ if (linuxUser) {
149
+ var uidGid = null;
150
+ try {
151
+ var passwdLine = execSync("id -u " + linuxUser + " && id -g " + linuxUser, { encoding: "utf8" }).trim().split("\n");
152
+ uidGid = { uid: parseInt(passwdLine[0], 10), gid: parseInt(passwdLine[1], 10) };
153
+ } catch (e) {}
154
+ if (uidGid) {
155
+ execSync("git init", { cwd: targetDir, uid: uidGid.uid, gid: uidGid.gid });
156
+ execSync("chown -R " + linuxUser + ":" + linuxUser + " " + JSON.stringify(targetDir));
157
+ } else {
158
+ execSync("git init", { cwd: targetDir });
159
+ }
160
+ } else {
161
+ execSync("git init", { cwd: targetDir });
162
+ }
163
+ } else {
164
+ execSync("git init", { cwd: targetDir });
165
+ }
166
+ } catch (e) {
167
+ try { fs.rmSync(targetDir, { recursive: true, force: true }); } catch (ce) {}
168
+ return { ok: false, error: "Failed to create project: " + e.message };
169
+ }
170
+ // Register project
171
+ var projectEntry = { path: targetDir, slug: slug, addedAt: Date.now() };
172
+ if (config.osUsers && wsUser) {
173
+ projectEntry.ownerId = wsUser.id;
174
+ }
175
+ relay.addProject(targetDir, slug);
176
+ config.projects.push(projectEntry);
177
+ saveConfig(config);
178
+ try { syncClayrc(config.projects); } catch (e) {}
179
+ console.log("[daemon] Created project:", slug, "→", targetDir);
180
+ // OS users mode: grant ACL
181
+ if (config.osUsers && wsUser && wsUser.linuxUser) {
182
+ grantProjectAccess(targetDir, wsUser.linuxUser);
183
+ }
184
+ relay.broadcastAll({
185
+ type: "projects_updated",
186
+ projects: relay.getProjects(),
187
+ projectCount: config.projects.length,
188
+ });
189
+ return { ok: true, slug: slug };
190
+ },
191
+ onCloneProject: function (cloneUrl, wsUser, callback) {
192
+ var os = require("os");
193
+ var { spawn, execSync } = require("child_process");
194
+ var baseDir;
195
+ if (config.osUsers) {
196
+ baseDir = "/var/clay/projects";
197
+ } else {
198
+ baseDir = config.projectsDir || path.join(os.homedir(), "clay-projects");
199
+ }
200
+ try { fs.mkdirSync(baseDir, { recursive: true }); } catch (e) {}
201
+ // Derive slug from repo URL
202
+ var repoName = cloneUrl.replace(/\.git$/, "").split("/").pop() || "project";
203
+ var slugs = config.projects.map(function (p) { return p.slug; });
204
+ var slug = generateSlug(path.join(baseDir, repoName), slugs);
205
+ var targetDir = path.join(baseDir, slug);
206
+ // Build spawn options
207
+ var spawnOpts = { cwd: baseDir };
208
+ if (config.osUsers && wsUser && wsUser.linuxUser) {
209
+ try {
210
+ var passwdLine = execSync("id -u " + wsUser.linuxUser + " && id -g " + wsUser.linuxUser, { encoding: "utf8" }).trim().split("\n");
211
+ spawnOpts.uid = parseInt(passwdLine[0], 10);
212
+ spawnOpts.gid = parseInt(passwdLine[1], 10);
213
+ } catch (e) {}
214
+ }
215
+ var proc = spawn("git", ["clone", cloneUrl, targetDir], spawnOpts);
216
+ var stderrBuf = "";
217
+ proc.stderr.on("data", function (chunk) { stderrBuf += chunk.toString(); });
218
+ // 5 minute timeout
219
+ var cloneTimeout = setTimeout(function () {
220
+ proc.kill("SIGTERM");
221
+ }, 5 * 60 * 1000);
222
+ proc.on("close", function (code) {
223
+ clearTimeout(cloneTimeout);
224
+ if (code !== 0) {
225
+ try { fs.rmSync(targetDir, { recursive: true, force: true }); } catch (ce) {}
226
+ var errMsg = stderrBuf.trim().split("\n").pop() || "Clone failed (exit code " + code + ")";
227
+ callback({ ok: false, error: errMsg });
228
+ return;
229
+ }
230
+ // chown if osUsers
231
+ if (config.osUsers && wsUser && wsUser.linuxUser) {
232
+ try { execSync("chown -R " + wsUser.linuxUser + ":" + wsUser.linuxUser + " " + JSON.stringify(targetDir)); } catch (e) {}
233
+ }
234
+ // Register project
235
+ var projectEntry = { path: targetDir, slug: slug, addedAt: Date.now() };
236
+ if (config.osUsers && wsUser) {
237
+ projectEntry.ownerId = wsUser.id;
238
+ }
239
+ relay.addProject(targetDir, slug);
240
+ config.projects.push(projectEntry);
241
+ saveConfig(config);
242
+ try { syncClayrc(config.projects); } catch (e) {}
243
+ console.log("[daemon] Cloned project:", slug, "→", targetDir);
244
+ if (config.osUsers && wsUser && wsUser.linuxUser) {
245
+ grantProjectAccess(targetDir, wsUser.linuxUser);
246
+ }
247
+ relay.broadcastAll({
248
+ type: "projects_updated",
249
+ projects: relay.getProjects(),
250
+ projectCount: config.projects.length,
251
+ });
252
+ callback({ ok: true, slug: slug });
253
+ });
254
+ proc.on("error", function (err) {
255
+ clearTimeout(cloneTimeout);
256
+ try { fs.rmSync(targetDir, { recursive: true, force: true }); } catch (ce) {}
257
+ callback({ ok: false, error: "Failed to start git clone: " + err.message });
258
+ });
259
+ },
116
260
  onRemoveProject: function (slug) {
117
261
  var found = false;
118
262
  for (var j = 0; j < config.projects.length; j++) {
@@ -211,6 +355,49 @@ var relay = createServer({
211
355
  });
212
356
  return { ok: true };
213
357
  },
358
+ onProjectOwnerChanged: function (slug, ownerId) {
359
+ var oldOwnerId = null;
360
+ var projectIdx = -1;
361
+ for (var oi = 0; oi < config.projects.length; oi++) {
362
+ if (config.projects[oi].slug === slug) {
363
+ oldOwnerId = config.projects[oi].ownerId || null;
364
+ projectIdx = oi;
365
+ if (ownerId) {
366
+ config.projects[oi].ownerId = ownerId;
367
+ } else {
368
+ delete config.projects[oi].ownerId;
369
+ }
370
+ break;
371
+ }
372
+ }
373
+ saveConfig(config);
374
+ // OS users mode: revoke old owner ACL, grant new owner ACL
375
+ if (config.osUsers && projectIdx >= 0) {
376
+ var projectPath = config.projects[projectIdx].path;
377
+ var allowed = config.projects[projectIdx].allowedUsers || [];
378
+ var visibility = config.projects[projectIdx].visibility || "public";
379
+ // Revoke old owner (if not in allowedUsers and project is not public)
380
+ if (oldOwnerId && oldOwnerId !== ownerId) {
381
+ var oldOwner = usersModule.findUserById(oldOwnerId);
382
+ if (oldOwner && oldOwner.linuxUser && allowed.indexOf(oldOwnerId) === -1 && visibility !== "public") {
383
+ revokeProjectAccess(projectPath, oldOwner.linuxUser);
384
+ }
385
+ }
386
+ // Grant new owner
387
+ if (ownerId) {
388
+ var newOwner = usersModule.findUserById(ownerId);
389
+ if (newOwner && newOwner.linuxUser) {
390
+ grantProjectAccess(projectPath, newOwner.linuxUser);
391
+ }
392
+ }
393
+ }
394
+ relay.broadcastAll({
395
+ type: "projects_updated",
396
+ projects: relay.getProjects(),
397
+ projectCount: config.projects.length,
398
+ });
399
+ return { ok: true };
400
+ },
214
401
  onGetProjectEnv: function (slug) {
215
402
  for (var ei = 0; ei < config.projects.length; ei++) {
216
403
  if (config.projects[ei].slug === slug) {
@@ -411,9 +598,31 @@ var relay = createServer({
411
598
  onSetProjectVisibility: function (slug, visibility) {
412
599
  for (var i = 0; i < config.projects.length; i++) {
413
600
  if (config.projects[i].slug === slug) {
601
+ var prevVisibility = config.projects[i].visibility || "public";
414
602
  config.projects[i].visibility = visibility;
415
603
  saveConfig(config);
416
604
  console.log("[daemon] Set project visibility:", slug, "→", visibility);
605
+ if (config.osUsers) {
606
+ var projectPath = config.projects[i].path;
607
+ var ownerId = config.projects[i].ownerId || null;
608
+ // When switching to public: grant ACL to ALL clay users
609
+ if (visibility === "public" && prevVisibility !== "public") {
610
+ grantAllUsersAccess(projectPath, usersModule);
611
+ }
612
+ // When switching to private: revoke ACLs for users not in allowedUsers and not the owner
613
+ if (visibility === "private" && prevVisibility !== "private") {
614
+ var allowed = config.projects[i].allowedUsers || [];
615
+ var allUsers = usersModule.getAllUsers();
616
+ for (var u = 0; u < allUsers.length; u++) {
617
+ var usr = allUsers[u];
618
+ if (usr.role === "admin") continue;
619
+ if (usr.id === ownerId) continue;
620
+ if (usr.linuxUser && allowed.indexOf(usr.id) === -1) {
621
+ revokeProjectAccess(projectPath, usr.linuxUser);
622
+ }
623
+ }
624
+ }
625
+ }
417
626
  return { ok: true };
418
627
  }
419
628
  }
@@ -422,9 +631,32 @@ var relay = createServer({
422
631
  onSetProjectAllowedUsers: function (slug, allowedUsers) {
423
632
  for (var i = 0; i < config.projects.length; i++) {
424
633
  if (config.projects[i].slug === slug) {
634
+ var prev = config.projects[i].allowedUsers || [];
425
635
  config.projects[i].allowedUsers = allowedUsers;
426
636
  saveConfig(config);
427
637
  console.log("[daemon] Set project allowed users:", slug, "→", allowedUsers.length, "users");
638
+ // OS users mode: sync ACLs for added/removed users
639
+ if (config.osUsers) {
640
+ var projectPath = config.projects[i].path;
641
+ // Grant access to newly added users
642
+ for (var a = 0; a < allowedUsers.length; a++) {
643
+ if (prev.indexOf(allowedUsers[a]) === -1) {
644
+ var addedUser = usersModule.findUserById(allowedUsers[a]);
645
+ if (addedUser && addedUser.linuxUser) {
646
+ grantProjectAccess(projectPath, addedUser.linuxUser);
647
+ }
648
+ }
649
+ }
650
+ // Revoke access from removed users
651
+ for (var r = 0; r < prev.length; r++) {
652
+ if (allowedUsers.indexOf(prev[r]) === -1) {
653
+ var removedUser = usersModule.findUserById(prev[r]);
654
+ if (removedUser && removedUser.linuxUser) {
655
+ revokeProjectAccess(projectPath, removedUser.linuxUser);
656
+ }
657
+ }
658
+ }
659
+ }
428
660
  return { ok: true };
429
661
  }
430
662
  }
@@ -442,6 +674,22 @@ var relay = createServer({
442
674
  }
443
675
  return { error: "Project not found" };
444
676
  },
677
+ onUserProvisioned: function (userId, linuxUser) {
678
+ // Grant ACL on all public projects to the newly provisioned user
679
+ if (!config.osUsers || !linuxUser) return;
680
+ for (var i = 0; i < config.projects.length; i++) {
681
+ var proj = config.projects[i];
682
+ var visibility = proj.visibility || "public";
683
+ if (visibility === "public") {
684
+ grantProjectAccess(proj.path, linuxUser);
685
+ }
686
+ }
687
+ },
688
+ onUserDeleted: function (userId, linuxUser) {
689
+ // Deactivate the Linux account when a Clay user is deleted
690
+ if (!config.osUsers || !linuxUser) return;
691
+ deactivateLinuxUser(linuxUser);
692
+ },
445
693
  });
446
694
 
447
695
  // --- Register projects ---
@@ -450,7 +698,7 @@ for (var i = 0; i < projects.length; i++) {
450
698
  var p = projects[i];
451
699
  if (fs.existsSync(p.path)) {
452
700
  console.log("[daemon] Adding project:", p.slug, "→", p.path);
453
- relay.addProject(p.path, p.slug, p.title, p.icon);
701
+ relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId);
454
702
  } else {
455
703
  console.log("[daemon] Skipping missing project:", p.path);
456
704
  }
@@ -527,6 +775,7 @@ var ipc = createIPCServer(socketPath(), function (msg) {
527
775
  port: config.port,
528
776
  tls: !!tlsOptions,
529
777
  keepAwake: !!config.keepAwake,
778
+ osUsers: !!config.osUsers,
530
779
  projects: relay.getProjects(),
531
780
  uptime: process.uptime(),
532
781
  };
@@ -563,6 +812,53 @@ var ipc = createIPCServer(socketPath(), function (msg) {
563
812
  return { ok: true };
564
813
  }
565
814
 
815
+ case "set_os_users": {
816
+ var enableOsUsers = !!msg.value;
817
+ config.osUsers = enableOsUsers;
818
+ saveConfig(config);
819
+ console.log("[daemon] OS users:", enableOsUsers);
820
+ if (enableOsUsers) {
821
+ // Ensure shared projects directory exists
822
+ try { ensureProjectsDir(); } catch (e) {
823
+ console.error("[daemon] Failed to create projects dir:", e.message);
824
+ }
825
+ // Auto-provision Linux accounts for all existing users
826
+ var provisionResult = provisionAllUsers(usersModule);
827
+ console.log("[daemon] Provisioning result: " +
828
+ provisionResult.provisioned.length + " provisioned, " +
829
+ provisionResult.skipped.length + " skipped, " +
830
+ provisionResult.errors.length + " errors");
831
+ // Set up ACLs for all existing projects
832
+ for (var pi = 0; pi < config.projects.length; pi++) {
833
+ var proj = config.projects[pi];
834
+ var projPath = proj.path;
835
+ var projVisibility = proj.visibility || "public";
836
+ // Grant ACL to project owner
837
+ if (proj.ownerId) {
838
+ var ownerUser = usersModule.findUserById(proj.ownerId);
839
+ if (ownerUser && ownerUser.linuxUser) {
840
+ grantProjectAccess(projPath, ownerUser.linuxUser);
841
+ }
842
+ }
843
+ // Public projects: grant ACL to all users
844
+ if (projVisibility === "public") {
845
+ grantAllUsersAccess(projPath, usersModule);
846
+ } else {
847
+ // Private projects: grant ACL to allowedUsers
848
+ var projAllowed = proj.allowedUsers || [];
849
+ for (var ai = 0; ai < projAllowed.length; ai++) {
850
+ var allowedUser = usersModule.findUserById(projAllowed[ai]);
851
+ if (allowedUser && allowedUser.linuxUser) {
852
+ grantProjectAccess(projPath, allowedUser.linuxUser);
853
+ }
854
+ }
855
+ }
856
+ }
857
+ return { ok: true, provisioning: provisionResult };
858
+ }
859
+ return { ok: true };
860
+ }
861
+
566
862
  case "set_keep_awake": {
567
863
  var want = !!msg.value;
568
864
  config.keepAwake = want;
@@ -663,6 +959,39 @@ function startListening() {
663
959
  config.pid = process.pid;
664
960
  saveConfig(config);
665
961
 
962
+ // Auto-provision Linux accounts on startup if OS users mode is enabled
963
+ if (config.osUsers) {
964
+ try { ensureProjectsDir(); } catch (e) {}
965
+ var provResult = provisionAllUsers(usersModule);
966
+ if (provResult.provisioned.length > 0) {
967
+ console.log("[daemon] Auto-provisioned " + provResult.provisioned.length + " Linux account(s) on startup");
968
+ }
969
+ if (provResult.errors.length > 0) {
970
+ console.error("[daemon] Failed to provision " + provResult.errors.length + " account(s)");
971
+ }
972
+ // Set up ACLs for all existing projects on startup
973
+ for (var pi = 0; pi < config.projects.length; pi++) {
974
+ var proj = config.projects[pi];
975
+ if (proj.ownerId) {
976
+ var ownerUser = usersModule.findUserById(proj.ownerId);
977
+ if (ownerUser && ownerUser.linuxUser) {
978
+ grantProjectAccess(proj.path, ownerUser.linuxUser);
979
+ }
980
+ }
981
+ if ((proj.visibility || "public") === "public") {
982
+ grantAllUsersAccess(proj.path, usersModule);
983
+ } else {
984
+ var projAllowed = proj.allowedUsers || [];
985
+ for (var ai = 0; ai < projAllowed.length; ai++) {
986
+ var allowedUser = usersModule.findUserById(projAllowed[ai]);
987
+ if (allowedUser && allowedUser.linuxUser) {
988
+ grantProjectAccess(proj.path, allowedUser.linuxUser);
989
+ }
990
+ }
991
+ }
992
+ }
993
+ }
994
+
666
995
  // Check for crash info from a previous crash and notify clients
667
996
  var crashInfo = readCrashInfo();
668
997
  if (crashInfo) {