clay-server 2.11.0-beta.4 → 2.11.0-beta.6
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 +157 -1
- package/lib/daemon.js +330 -1
- package/lib/os-users.js +301 -0
- package/lib/project.js +210 -39
- package/lib/public/app.js +180 -11
- package/lib/public/css/admin.css +6 -0
- package/lib/public/css/filebrowser.css +22 -0
- package/lib/public/css/messages.css +63 -0
- package/lib/public/css/overlays.css +88 -0
- package/lib/public/index.html +32 -3
- package/lib/public/modules/admin.js +44 -6
- package/lib/public/modules/project-settings.js +118 -0
- package/lib/public/modules/sidebar.js +9 -9
- package/lib/public/modules/terminal.js +21 -8
- package/lib/sdk-bridge.js +558 -6
- package/lib/sdk-worker.js +446 -0
- package/lib/server.js +124 -1
- package/lib/terminal-manager.js +2 -2
- package/lib/terminal.js +20 -4
- package/lib/users.js +43 -0
- package/package.json +1 -1
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);
|
|
@@ -292,7 +296,33 @@ if (multiUserMode) {
|
|
|
292
296
|
console.log("The code is single-use and will be cleared once the admin is set up.");
|
|
293
297
|
console.log("");
|
|
294
298
|
}
|
|
295
|
-
|
|
299
|
+
if (!osUsersMode) {
|
|
300
|
+
process.exit(0);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- Handle --os-users validation ---
|
|
305
|
+
if (osUsersMode) {
|
|
306
|
+
if (process.platform !== "linux") {
|
|
307
|
+
console.error("\x1b[31mError: --os-users requires Linux.\x1b[0m");
|
|
308
|
+
console.error("OS-level user isolation depends on setfacl, getent, and uid/gid process spawning.");
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
if (typeof process.getuid === "function" && process.getuid() !== 0) {
|
|
312
|
+
console.error("\x1b[31mError: --os-users requires running as root.\x1b[0m");
|
|
313
|
+
console.error("The daemon must run as root to spawn worker processes as different users.");
|
|
314
|
+
console.error("Use: sudo clay --multi-user --os-users");
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
if (!isMultiUser()) {
|
|
318
|
+
console.error("\x1b[31mError: --os-users requires --multi-user mode.\x1b[0m");
|
|
319
|
+
console.error("Enable multi-user mode first: clay --multi-user");
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
console.log("\x1b[32mOS-level user isolation enabled.\x1b[0m");
|
|
323
|
+
console.log("Worker processes will run as mapped Linux users.");
|
|
324
|
+
console.log("Linux accounts will be auto-provisioned for existing users on startup.");
|
|
325
|
+
console.log("");
|
|
296
326
|
}
|
|
297
327
|
|
|
298
328
|
var cwd = process.cwd();
|
|
@@ -460,6 +490,7 @@ async function restartDaemonFromConfig() {
|
|
|
460
490
|
debug: lastConfig.debug || false,
|
|
461
491
|
keepAwake: lastConfig.keepAwake || false,
|
|
462
492
|
dangerouslySkipPermissions: lastConfig.dangerouslySkipPermissions || false,
|
|
493
|
+
osUsers: lastConfig.osUsers || false,
|
|
463
494
|
projects: (lastConfig.projects || []).filter(function (p) {
|
|
464
495
|
return fs.existsSync(p.path);
|
|
465
496
|
}),
|
|
@@ -1408,6 +1439,7 @@ async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
|
|
|
1408
1439
|
debug: debugMode,
|
|
1409
1440
|
keepAwake: keepAwake,
|
|
1410
1441
|
dangerouslySkipPermissions: dangerouslySkipPermissions,
|
|
1442
|
+
osUsers: osUsersMode,
|
|
1411
1443
|
projects: allProjects,
|
|
1412
1444
|
};
|
|
1413
1445
|
|
|
@@ -2268,6 +2300,7 @@ function showSetupGuide(config, ip, goBack) {
|
|
|
2268
2300
|
function showSettingsMenu(config, ip) {
|
|
2269
2301
|
sendIPCCommand(socketPath(), { cmd: "get_status" }).then(function (status) {
|
|
2270
2302
|
var isAwake = status && status.keepAwake;
|
|
2303
|
+
var isOsUsers = status && status.osUsers;
|
|
2271
2304
|
|
|
2272
2305
|
console.clear();
|
|
2273
2306
|
printLogo();
|
|
@@ -2306,6 +2339,12 @@ function showSettingsMenu(config, ip) {
|
|
|
2306
2339
|
|
|
2307
2340
|
log(sym.bar + " PIN " + pinStatus);
|
|
2308
2341
|
log(sym.bar + " Multi-user " + muStatus);
|
|
2342
|
+
var osUsersStatus = isOsUsers
|
|
2343
|
+
? a.green + "Enabled" + a.reset
|
|
2344
|
+
: a.dim + "Off" + a.reset;
|
|
2345
|
+
if (muEnabled) {
|
|
2346
|
+
log(sym.bar + " OS users " + osUsersStatus);
|
|
2347
|
+
}
|
|
2309
2348
|
if (process.platform === "darwin") {
|
|
2310
2349
|
log(sym.bar + " Keep awake " + awakeStatus);
|
|
2311
2350
|
}
|
|
@@ -2324,6 +2363,11 @@ function showSettingsMenu(config, ip) {
|
|
|
2324
2363
|
}
|
|
2325
2364
|
if (muEnabled) {
|
|
2326
2365
|
items.push({ label: "Disable multi-user mode", value: "disable_multi_user" });
|
|
2366
|
+
if (isOsUsers) {
|
|
2367
|
+
items.push({ label: "Disable OS-level user isolation", value: "disable_os_users" });
|
|
2368
|
+
} else {
|
|
2369
|
+
items.push({ label: "Enable OS-level user isolation", value: "os_users" });
|
|
2370
|
+
}
|
|
2327
2371
|
} else {
|
|
2328
2372
|
items.push({ label: "Enable multi-user mode", value: "multi_user" });
|
|
2329
2373
|
}
|
|
@@ -2416,6 +2460,118 @@ function showSettingsMenu(config, ip) {
|
|
|
2416
2460
|
});
|
|
2417
2461
|
break;
|
|
2418
2462
|
|
|
2463
|
+
case "os_users":
|
|
2464
|
+
if (process.platform === "win32") {
|
|
2465
|
+
log(sym.bar);
|
|
2466
|
+
log(sym.bar + " " + a.red + "OS-level user isolation is not supported on Windows." + a.reset);
|
|
2467
|
+
log(sym.bar);
|
|
2468
|
+
promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
|
|
2469
|
+
showSettingsMenu(config, ip);
|
|
2470
|
+
});
|
|
2471
|
+
break;
|
|
2472
|
+
}
|
|
2473
|
+
if (process.getuid() !== 0) {
|
|
2474
|
+
log(sym.bar);
|
|
2475
|
+
log(sym.bar + " " + a.red + "Requires running as root." + a.reset);
|
|
2476
|
+
log(sym.bar);
|
|
2477
|
+
promptSelect("Back?", [{ label: "Back", value: "back" }], function () {
|
|
2478
|
+
showSettingsMenu(config, ip);
|
|
2479
|
+
});
|
|
2480
|
+
break;
|
|
2481
|
+
}
|
|
2482
|
+
if (process.platform !== "linux") {
|
|
2483
|
+
log(sym.bar);
|
|
2484
|
+
log(sym.bar + " " + a.red + sym.warn + " OS-level user isolation requires Linux." + a.reset);
|
|
2485
|
+
log(sym.bar + " " + a.dim + "This feature depends on setfacl, getent, and uid/gid process spawning." + a.reset);
|
|
2486
|
+
log(sym.bar + " " + a.dim + "Use Docker or a Linux VM to run Clay with OS user isolation." + a.reset);
|
|
2487
|
+
log(sym.bar);
|
|
2488
|
+
showSettingsMenu(config, ip);
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2491
|
+
log(sym.bar);
|
|
2492
|
+
log(sym.bar + " " + a.yellow + sym.warn + " OS-Level User Isolation" + a.reset);
|
|
2493
|
+
log(sym.bar);
|
|
2494
|
+
log(sym.bar + " " + a.dim + "This feature maps each Clay user to a Linux OS user account." + a.reset);
|
|
2495
|
+
log(sym.bar + " " + a.dim + "The daemon must run as root and will spawn processes (SDK workers," + a.reset);
|
|
2496
|
+
log(sym.bar + " " + a.dim + "terminals, file operations) as the mapped Linux user." + a.reset);
|
|
2497
|
+
log(sym.bar);
|
|
2498
|
+
log(sym.bar + " " + a.dim + "What this means:" + a.reset);
|
|
2499
|
+
log(sym.bar + " " + a.dim + "- Each mapped user uses their own ~/.claude/ credentials" + a.reset);
|
|
2500
|
+
log(sym.bar + " " + a.dim + "- Terminals and file access follow Linux permissions" + a.reset);
|
|
2501
|
+
log(sym.bar + " " + a.dim + "- Linux user accounts are created automatically (clay-username)" + a.reset);
|
|
2502
|
+
log(sym.bar);
|
|
2503
|
+
log(sym.bar + " " + a.dim + "Recommended: Run on a dedicated Clay server or cloud instance," + a.reset);
|
|
2504
|
+
log(sym.bar + " " + a.dim + "not on a personal computer or general-purpose server." + a.reset);
|
|
2505
|
+
log(sym.bar);
|
|
2506
|
+
promptSelect("Select", [
|
|
2507
|
+
{ label: "Enable OS-level user isolation", value: "confirm" },
|
|
2508
|
+
{ label: "Cancel", value: "cancel" },
|
|
2509
|
+
], function (confirmChoice) {
|
|
2510
|
+
if (confirmChoice === "confirm") {
|
|
2511
|
+
sendIPCCommand(socketPath(), { cmd: "set_os_users", value: true }).then(function (res) {
|
|
2512
|
+
if (res.ok) {
|
|
2513
|
+
config.osUsers = true;
|
|
2514
|
+
log(sym.bar);
|
|
2515
|
+
log(sym.done + " " + a.green + "OS-level user isolation enabled." + a.reset);
|
|
2516
|
+
if (res.provisioning) {
|
|
2517
|
+
var p = res.provisioning;
|
|
2518
|
+
if (p.provisioned.length > 0) {
|
|
2519
|
+
log(sym.bar);
|
|
2520
|
+
log(sym.bar + " " + a.green + "Provisioned " + p.provisioned.length + " Linux account(s):" + a.reset);
|
|
2521
|
+
for (var pi = 0; pi < p.provisioned.length; pi++) {
|
|
2522
|
+
log(sym.bar + " " + a.dim + p.provisioned[pi].username + " -> " + p.provisioned[pi].linuxUser + a.reset);
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
if (p.skipped.length > 0) {
|
|
2526
|
+
log(sym.bar + " " + a.dim + p.skipped.length + " user(s) already mapped." + a.reset);
|
|
2527
|
+
}
|
|
2528
|
+
if (p.errors.length > 0) {
|
|
2529
|
+
log(sym.bar);
|
|
2530
|
+
log(sym.bar + " " + a.red + p.errors.length + " user(s) failed to provision:" + a.reset);
|
|
2531
|
+
for (var ei = 0; ei < p.errors.length; ei++) {
|
|
2532
|
+
log(sym.bar + " " + a.red + p.errors[ei].username + ": " + p.errors[ei].error + a.reset);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
log(sym.bar + " " + a.dim + "Restart the daemon for changes to take full effect." + a.reset);
|
|
2537
|
+
log(sym.bar);
|
|
2538
|
+
}
|
|
2539
|
+
showSettingsMenu(config, ip);
|
|
2540
|
+
});
|
|
2541
|
+
} else {
|
|
2542
|
+
showSettingsMenu(config, ip);
|
|
2543
|
+
}
|
|
2544
|
+
});
|
|
2545
|
+
break;
|
|
2546
|
+
|
|
2547
|
+
case "disable_os_users":
|
|
2548
|
+
log(sym.bar);
|
|
2549
|
+
log(sym.bar + " " + a.yellow + sym.warn + " Disable OS-level user isolation?" + a.reset);
|
|
2550
|
+
log(sym.bar);
|
|
2551
|
+
log(sym.bar + " " + a.dim + "Processes will no longer be spawned as mapped Linux users." + a.reset);
|
|
2552
|
+
log(sym.bar + " " + a.dim + "User mappings will be preserved and restored if re-enabled." + a.reset);
|
|
2553
|
+
log(sym.bar);
|
|
2554
|
+
promptSelect("Confirm", [
|
|
2555
|
+
{ label: "Disable OS-level user isolation", value: "confirm" },
|
|
2556
|
+
{ label: "Cancel", value: "cancel" },
|
|
2557
|
+
], function (confirmChoice) {
|
|
2558
|
+
if (confirmChoice === "confirm") {
|
|
2559
|
+
sendIPCCommand(socketPath(), { cmd: "set_os_users", value: false }).then(function (res) {
|
|
2560
|
+
if (res.ok) {
|
|
2561
|
+
config.osUsers = false;
|
|
2562
|
+
log(sym.bar);
|
|
2563
|
+
log(sym.done + " " + a.green + "OS-level user isolation disabled." + a.reset);
|
|
2564
|
+
log(sym.bar + " " + a.dim + "Restart the daemon for changes to take full effect." + a.reset);
|
|
2565
|
+
log(sym.bar);
|
|
2566
|
+
}
|
|
2567
|
+
showSettingsMenu(config, ip);
|
|
2568
|
+
});
|
|
2569
|
+
} else {
|
|
2570
|
+
showSettingsMenu(config, ip);
|
|
2571
|
+
}
|
|
2572
|
+
});
|
|
2573
|
+
break;
|
|
2574
|
+
|
|
2419
2575
|
case "logs":
|
|
2420
2576
|
console.clear();
|
|
2421
2577
|
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) {
|