clay-server 2.11.0-beta.3 → 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 +154 -0
- package/lib/daemon.js +341 -2
- package/lib/os-users.js +301 -0
- package/lib/project.js +224 -42
- package/lib/public/app.js +190 -11
- package/lib/public/css/admin.css +56 -10
- 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/css/server-settings.css +30 -2
- package/lib/public/index.html +124 -66
- package/lib/public/modules/admin.js +48 -11
- package/lib/public/modules/notifications.js +3 -1
- package/lib/public/modules/project-settings.js +154 -168
- package/lib/public/modules/server-settings.js +78 -189
- package/lib/public/modules/settings-defaults.js +243 -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 +127 -1
- package/lib/terminal-manager.js +2 -2
- package/lib/terminal.js +20 -4
- package/lib/updater.js +38 -11
- 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);
|
|
@@ -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) {
|
|
@@ -355,8 +542,17 @@ var relay = createServer({
|
|
|
355
542
|
keepAwake: !!config.keepAwake,
|
|
356
543
|
pinEnabled: !!config.pinHash,
|
|
357
544
|
platform: process.platform,
|
|
545
|
+
hostname: os2.hostname(),
|
|
546
|
+
lanIp: lanIp || null,
|
|
547
|
+
updateChannel: config.updateChannel || "stable",
|
|
358
548
|
};
|
|
359
549
|
},
|
|
550
|
+
onSetUpdateChannel: function (channel) {
|
|
551
|
+
config.updateChannel = channel === "beta" ? "beta" : "stable";
|
|
552
|
+
saveConfig(config);
|
|
553
|
+
console.log("[daemon] Update channel:", config.updateChannel, "(web)");
|
|
554
|
+
return { ok: true, updateChannel: config.updateChannel };
|
|
555
|
+
},
|
|
360
556
|
onSetPin: function (pin) {
|
|
361
557
|
if (pin) {
|
|
362
558
|
config.pinHash = generateAuthToken(pin);
|
|
@@ -402,9 +598,31 @@ var relay = createServer({
|
|
|
402
598
|
onSetProjectVisibility: function (slug, visibility) {
|
|
403
599
|
for (var i = 0; i < config.projects.length; i++) {
|
|
404
600
|
if (config.projects[i].slug === slug) {
|
|
601
|
+
var prevVisibility = config.projects[i].visibility || "public";
|
|
405
602
|
config.projects[i].visibility = visibility;
|
|
406
603
|
saveConfig(config);
|
|
407
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
|
+
}
|
|
408
626
|
return { ok: true };
|
|
409
627
|
}
|
|
410
628
|
}
|
|
@@ -413,9 +631,32 @@ var relay = createServer({
|
|
|
413
631
|
onSetProjectAllowedUsers: function (slug, allowedUsers) {
|
|
414
632
|
for (var i = 0; i < config.projects.length; i++) {
|
|
415
633
|
if (config.projects[i].slug === slug) {
|
|
634
|
+
var prev = config.projects[i].allowedUsers || [];
|
|
416
635
|
config.projects[i].allowedUsers = allowedUsers;
|
|
417
636
|
saveConfig(config);
|
|
418
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
|
+
}
|
|
419
660
|
return { ok: true };
|
|
420
661
|
}
|
|
421
662
|
}
|
|
@@ -433,6 +674,22 @@ var relay = createServer({
|
|
|
433
674
|
}
|
|
434
675
|
return { error: "Project not found" };
|
|
435
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
|
+
},
|
|
436
693
|
});
|
|
437
694
|
|
|
438
695
|
// --- Register projects ---
|
|
@@ -441,7 +698,7 @@ for (var i = 0; i < projects.length; i++) {
|
|
|
441
698
|
var p = projects[i];
|
|
442
699
|
if (fs.existsSync(p.path)) {
|
|
443
700
|
console.log("[daemon] Adding project:", p.slug, "→", p.path);
|
|
444
|
-
relay.addProject(p.path, p.slug, p.title, p.icon);
|
|
701
|
+
relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId);
|
|
445
702
|
} else {
|
|
446
703
|
console.log("[daemon] Skipping missing project:", p.path);
|
|
447
704
|
}
|
|
@@ -518,6 +775,7 @@ var ipc = createIPCServer(socketPath(), function (msg) {
|
|
|
518
775
|
port: config.port,
|
|
519
776
|
tls: !!tlsOptions,
|
|
520
777
|
keepAwake: !!config.keepAwake,
|
|
778
|
+
osUsers: !!config.osUsers,
|
|
521
779
|
projects: relay.getProjects(),
|
|
522
780
|
uptime: process.uptime(),
|
|
523
781
|
};
|
|
@@ -554,6 +812,53 @@ var ipc = createIPCServer(socketPath(), function (msg) {
|
|
|
554
812
|
return { ok: true };
|
|
555
813
|
}
|
|
556
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
|
+
|
|
557
862
|
case "set_keep_awake": {
|
|
558
863
|
var want = !!msg.value;
|
|
559
864
|
config.keepAwake = want;
|
|
@@ -595,11 +900,12 @@ var ipc = createIPCServer(socketPath(), function (msg) {
|
|
|
595
900
|
|
|
596
901
|
// Production: fetch latest via npx, then spawn updated daemon
|
|
597
902
|
var { execSync: execSyncUpd, spawn: spawnUpd } = require("child_process");
|
|
903
|
+
var updTag = config.updateChannel === "beta" ? "beta" : "latest";
|
|
598
904
|
var updDaemonScript;
|
|
599
905
|
try {
|
|
600
906
|
// npx downloads the package and puts a bin symlink; `which` prints its path
|
|
601
907
|
var binPath = execSyncUpd(
|
|
602
|
-
"npx --yes --package=clay-server@
|
|
908
|
+
"npx --yes --package=clay-server@" + updTag + " -- which clay-server",
|
|
603
909
|
{ stdio: ["ignore", "pipe", "pipe"], timeout: 120000, encoding: "utf8" }
|
|
604
910
|
).trim();
|
|
605
911
|
// Resolve symlink to get the actual package directory
|
|
@@ -653,6 +959,39 @@ function startListening() {
|
|
|
653
959
|
config.pid = process.pid;
|
|
654
960
|
saveConfig(config);
|
|
655
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
|
+
|
|
656
995
|
// Check for crash info from a previous crash and notify clients
|
|
657
996
|
var crashInfo = readCrashInfo();
|
|
658
997
|
if (crashInfo) {
|