// os-users.js — Shared utility for resolving Linux OS user information. // Used by sdk-bridge.js (worker spawning), terminal-manager.js, and project.js (file ops). var fs = require("fs"); var path = require("path"); var execFileSync = require("child_process").execFileSync; var _aclGrantCache = Object.create(null); function aclCacheKey(projectPath, linuxUser) { return path.resolve(projectPath) + "::" + linuxUser; } function isSafeLinuxUsername(username) { return typeof username === "string" && /^[a-z_][a-z0-9_-]*[$]?$/.test(username); } // --- Supplementary group inheritance (issue #367) --- // Node's child_process { uid, gid } options call setuid/setgid but never // initgroups(), so spawned sessions drop all supplementary groups (docker, // video, wheel, ...). Route user-switched spawns through // `setpriv --init-groups` so the child gets the same groups as a normal login. var _setprivAvail = null; var _warnedNoSetpriv = false; function setprivAvailable() { if (_setprivAvail !== null) return _setprivAvail; try { execFileSync("setpriv", ["--help"], { stdio: "ignore", timeout: 5000 }); _setprivAvail = true; } catch (e) { _setprivAvail = false; } return _setprivAvail; } /** * Wrap a spawn/exec invocation so the child runs as the target user WITH its * supplementary groups initialized (initgroups), which Node's uid/gid options * do not do. Accepts (command, args, options) where options may carry * uid/gid, and returns a normalized { command, args, options } to pass to * spawn()/execFileSync()/pty.spawn(). * * - Same-user spawns (no uid/gid in options) are returned unchanged. * - When setpriv is unavailable, falls back to * Node's uid/gid options (primary group only) so behavior degrades safely. * Requires the parent process to be root, which is the case in os-users mode. */ function wrapSpawnAsUser(command, args, options, setprivCheck) { args = args || []; options = options || {}; var uid = options.uid; var gid = options.gid; if (uid == null || gid == null) return { command: command, args: args, options: options }; var hasSetpriv = typeof setprivCheck === "function" ? setprivCheck() : setprivAvailable(); if (hasSetpriv) { var wrappedArgs = ["--reuid", String(uid), "--regid", String(gid), "--init-groups", "--", command].concat(args); var newOpts = Object.assign({}, options); delete newOpts.uid; delete newOpts.gid; return { command: "setpriv", args: wrappedArgs, options: newOpts }; } if (!_warnedNoSetpriv) { _warnedNoSetpriv = true; console.warn("[os-users] `setpriv` not found; supplementary groups will be dropped. Install util-linux to restore them."); } return { command: command, args: args, options: options }; } /** * Resolve Linux user info from username via getent passwd. * Returns { uid, gid, home, user, shell } or throws on failure. */ function resolveOsUserInfo(username) { if (!isSafeLinuxUsername(username)) throw new Error("Invalid Linux username"); var output = execFileSync("getent", ["passwd", username], { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim(); // getent passwd format: username:x:uid:gid:gecos:home:shell var parts = output.split(":"); if (parts.length < 7) throw new Error("Unexpected getent output for user " + username); return { uid: parseInt(parts[2], 10), gid: parseInt(parts[3], 10), home: parts[5], user: parts[0], shell: parts[6] || "/bin/bash", }; } /** * Run a file system operation as a specific OS user via a helper subprocess. * op: "list", "read", "write", "read_binary", "mkdir", "copy", "stat" * args: operation-specific arguments * osUserInfo: { uid, gid } from resolveOsUserInfo * Returns the result object or throws. */ function fsAsUser(op, args, osUserInfo) { // Build a small inline Node script to run as the target user var script; if (op === "list") { script = [ "var fs = require('fs');", "var p = require('path');", "var dir = " + JSON.stringify(args.dir) + ";", "var entries = fs.readdirSync(dir, { withFileTypes: true });", "var result = [];", "for (var i = 0; i < entries.length; i++) {", " var e = entries[i];", " var stat;", " try { stat = fs.statSync(p.join(dir, e.name)); } catch(err) { continue; }", " result.push({ name: e.name, isDir: e.isDirectory(), size: stat.size, mtime: stat.mtimeMs });", "}", "process.stdout.write(JSON.stringify(result));", ].join(" "); } else if (op === "read") { script = [ "var fs = require('fs');", "var f = " + JSON.stringify(args.file) + ";", "var stat = fs.statSync(f);", "var result = { size: stat.size };", "if (" + JSON.stringify(!!args.readContent) + ") {", " result.content = fs.readFileSync(f, 'utf8');", "}", "process.stdout.write(JSON.stringify(result));", ].join(" "); } else if (op === "stat") { script = [ "var fs = require('fs');", "var f = " + JSON.stringify(args.file) + ";", "var stat = fs.statSync(f);", "process.stdout.write(JSON.stringify({ size: stat.size, isDir: stat.isDirectory(), mtime: stat.mtimeMs }));", ].join(" "); } else if (op === "realpath") { script = [ "var fs = require('fs');", "var f = " + JSON.stringify(args.file) + ";", "process.stdout.write(JSON.stringify({ path: fs.realpathSync(f) }));", ].join(" "); } else if (op === "read_binary") { // Read file as base64 for binary content (images, etc.) script = [ "var fs = require('fs');", "var f = " + JSON.stringify(args.file) + ";", "var buf = fs.readFileSync(f);", "process.stdout.write(buf.toString('base64'));", ].join(" "); var binSpawn = wrapSpawnAsUser(process.execPath, ["-e", script], { encoding: "utf8", timeout: 10000, uid: osUserInfo.uid, gid: osUserInfo.gid, maxBuffer: 50 * 1024 * 1024, }); var binOutput = execFileSync(binSpawn.command, binSpawn.args, binSpawn.options); return { buffer: Buffer.from(binOutput.trim(), "base64") }; } else if (op === 'write_exact') { script = [ "var fs = require('fs'), crypto = require('crypto'), path = require('path');", "var f = " + JSON.stringify(args.file) + "; var expected = " + JSON.stringify(args.expected) + ";", "var root = " + JSON.stringify(args.root) + "; var relative = path.relative(root, f);", "function checkPath() { if (fs.realpathSync(root) !== root || path.isAbsolute(relative) || relative.split(path.sep).indexOf('..') >= 0) throw new Error('The canonical file boundary changed.'); var at = root; relative.split(path.sep).forEach(function (part) { at = path.join(at, part); if (fs.lstatSync(at).isSymbolicLink()) throw new Error('File parent symlinks are unavailable.'); }); }", "checkPath();", "var fd = fs.openSync(f, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW);", "try { var s = fs.fstatSync(fd);", "if (!s.isFile() || s.nlink !== 1 || s.dev !== expected.dev || s.ino !== expected.ino || s.mode !== expected.mode || s.uid !== expected.uid || s.gid !== expected.gid || s.size !== expected.size || s.mtimeMs !== expected.mtime || crypto.createHash('sha256').update(fs.readFileSync(fd, 'utf8')).digest('hex') !== expected.hash) throw new Error('The exact file revision changed.');", "checkPath(); fs.ftruncateSync(fd, 0); var bytes = Buffer.from(" + JSON.stringify(args.content) + ", 'utf8'), offset = 0; while (offset < bytes.length) { var written = fs.writeSync(fd, bytes, offset, bytes.length - offset, offset); if (!written) throw new Error('File write made no progress.'); offset += written; } fs.fsyncSync(fd); } finally { fs.closeSync(fd); }", "process.stdout.write(JSON.stringify({ ok: true }));", ].join(' '); } else if (op === "write") { script = [ "var fs = require('fs');", "var f = " + JSON.stringify(args.file) + ";", "var content = " + JSON.stringify(args.content || "") + ";", "var flag = " + JSON.stringify(args.flag === "wx" ? "wx" : "w") + ";", "var mode = " + JSON.stringify(typeof args.mode === "number" ? args.mode : 0o666) + ";", "fs.writeFileSync(f, content, { encoding: 'utf8', flag: flag, mode: mode });", "process.stdout.write(JSON.stringify({ ok: true }));", ].join(" "); } else if (op === "mkdir") { script = [ "var fs = require('fs');", "var dir = " + JSON.stringify(args.dir) + ";", "fs.mkdirSync(dir, { recursive: true });", "process.stdout.write(JSON.stringify({ ok: true }));", ].join(" "); } else if (op === "copy") { script = [ "var fs = require('fs');", "var source = " + JSON.stringify(args.source) + ";", "var file = " + JSON.stringify(args.file) + ";", "var overwrite = " + JSON.stringify(!!args.overwrite) + ";", "fs.copyFileSync(source, file, overwrite ? 0 : fs.constants.COPYFILE_EXCL);", "process.stdout.write(JSON.stringify({ ok: true }));", ].join(" "); } else { throw new Error("Unknown fsAsUser operation: " + op); } var fsSpawn = wrapSpawnAsUser(process.execPath, ["-e", script], { encoding: "utf8", timeout: 10000, uid: osUserInfo.uid, gid: osUserInfo.gid, }); var output = execFileSync(fsSpawn.command, fsSpawn.args, fsSpawn.options); return JSON.parse(output.trim()); } /** * Detect the Linux distribution family for package manager guidance. * Returns "debian", "rhel", "alpine", "suse", "arch", or "unknown". */ function detectDistroFamily() { try { var release = fs.readFileSync("/etc/os-release", "utf8"); var idLike = ""; var id = ""; var lines = release.split("\n"); for (var i = 0; i < lines.length; i++) { if (lines[i].indexOf("ID_LIKE=") === 0) idLike = lines[i].substring(8).replace(/"/g, "").toLowerCase(); if (lines[i].indexOf("ID=") === 0 && lines[i].indexOf("ID_LIKE=") !== 0) id = lines[i].substring(3).replace(/"/g, "").toLowerCase(); } var all = id + " " + idLike; if (all.indexOf("debian") !== -1 || all.indexOf("ubuntu") !== -1) return "debian"; if (all.indexOf("rhel") !== -1 || all.indexOf("centos") !== -1 || all.indexOf("fedora") !== -1 || all.indexOf("amzn") !== -1 || all.indexOf("amazon") !== -1) return "rhel"; if (all.indexOf("alpine") !== -1) return "alpine"; if (all.indexOf("suse") !== -1 || all.indexOf("opensuse") !== -1) return "suse"; if (all.indexOf("arch") !== -1 || all.indexOf("manjaro") !== -1) return "arch"; return "unknown"; } catch (e) { return "unknown"; } } /** * Build a user-friendly install command for the ACL package based on distro. */ function getAclInstallCommand() { var distro = detectDistroFamily(); switch (distro) { case "debian": return "sudo apt install -y acl"; case "rhel": return "sudo yum install -y acl"; case "alpine": return "sudo apk add acl"; case "suse": return "sudo zypper install -y acl"; case "arch": return "sudo pacman -S --noconfirm acl"; default: return "Install the 'acl' package using your distribution's package manager (apt, yum, apk, etc.)"; } } /** * Check if setfacl is available on the system. * Returns { available: true } or { available: false, installCmd: "..." }. */ function checkAclSupport() { try { execFileSync("which", ["setfacl"], { encoding: "utf8", timeout: 5000, stdio: "pipe" }); return { available: true }; } catch (e) { return { available: false, installCmd: getAclInstallCommand() }; } } /** * Check if a path is a user's home directory (e.g. /home/chad, /root). * Running recursive setfacl on a home dir is dangerous and slow. */ function isHomeDirectory(dirPath) { var resolved = path.resolve(dirPath); // /root if (resolved === "/root") return true; // /home/username (exactly two levels) var parts = resolved.split("/"); if (parts.length === 3 && parts[0] === "" && parts[1] === "home" && parts[2]) return true; return false; } /** * Grant a Linux user ACL access (rwX) to a project directory. * Uses setfacl to add recursive + default ACL entries. * Skips home directories to avoid slow recursive ACL on large trees. */ function grantProjectAccess(projectPath, linuxUser, options) { if (isHomeDirectory(projectPath)) { console.log("[os-users] Skipping ACL for home directory: " + projectPath); return true; } if (!isSafeLinuxUsername(linuxUser)) { console.error("[os-users] Invalid Linux username for ACL grant: " + linuxUser); return false; } var cacheKey = aclCacheKey(projectPath, linuxUser); if (_aclGrantCache[cacheKey] && !(options && options.force)) { return true; } try { // Recursive ACL for existing files execFileSync("setfacl", ["-R", "-m", "u:" + linuxUser + ":rwX", projectPath], { encoding: "utf8", timeout: 30000, stdio: "pipe", }); // Default ACL so new files also inherit access execFileSync("setfacl", ["-R", "-d", "-m", "u:" + linuxUser + ":rwX", projectPath], { encoding: "utf8", timeout: 30000, stdio: "pipe", }); _aclGrantCache[cacheKey] = true; console.log("[os-users] Granted ACL access for " + linuxUser + " on " + projectPath); return true; } catch (e) { delete _aclGrantCache[cacheKey]; var errMsg = (e.stderr || e.message || "").toString(); if (errMsg.indexOf("not found") !== -1 || errMsg.indexOf("ENOENT") !== -1) { var cmd = getAclInstallCommand(); console.error("[os-users] setfacl is not installed. ACL support is required for OS user isolation."); console.error("[os-users] Install it with: " + cmd); } else { console.error("[os-users] Failed to grant ACL access for " + linuxUser + " on " + projectPath + ": " + errMsg); } return false; } } /** * Revoke a Linux user's ACL access from a project directory. */ function revokeProjectAccess(projectPath, linuxUser) { if (isHomeDirectory(projectPath)) { console.log("[os-users] Skipping ACL revoke for home directory: " + projectPath); return; } if (!isSafeLinuxUsername(linuxUser)) { console.error("[os-users] Invalid Linux username for ACL revoke: " + linuxUser); return; } var cacheKey = aclCacheKey(projectPath, linuxUser); try { execFileSync("setfacl", ["-R", "-x", "u:" + linuxUser, projectPath], { encoding: "utf8", timeout: 30000, stdio: "pipe", }); execFileSync("setfacl", ["-R", "-d", "-x", "u:" + linuxUser, projectPath], { encoding: "utf8", timeout: 30000, stdio: "pipe", }); delete _aclGrantCache[cacheKey]; console.log("[os-users] Revoked ACL access for " + linuxUser + " on " + projectPath); } catch (e) { var errMsg = (e.stderr || e.message || "").toString(); if (errMsg.indexOf("not found") !== -1 || errMsg.indexOf("ENOENT") !== -1) { var cmd = getAclInstallCommand(); console.error("[os-users] setfacl is not installed. ACL support is required for OS user isolation."); console.error("[os-users] Install it with: " + cmd); } else { console.error("[os-users] Failed to revoke ACL access for " + linuxUser + " on " + projectPath + ": " + errMsg); } } } /** * Sanitize a Clay username into a valid Linux username. * Prefixes with "clay-", lowercases, replaces invalid chars with hyphens. * Linux usernames: start with [a-z_], then [a-z0-9_-], max 32 chars. */ function toLinuxUsername(clayUsername) { var sanitized = clayUsername.toLowerCase().replace(/[^a-z0-9_-]/g, "-"); // Remove leading hyphens/digits after prefix sanitized = sanitized.replace(/^[-0-9]+/, ""); if (!sanitized) sanitized = "user"; var name = "clay-" + sanitized; // Truncate to 32 chars (Linux limit) if (name.length > 32) name = name.substring(0, 32); // Remove trailing hyphens name = name.replace(/-+$/, ""); return name; } function findAvailableLinuxUsername(baseName, existsFn, rejectedCandidates) { var exists = existsFn || linuxUserExists; rejectedCandidates = rejectedCandidates || Object.create(null); if (!exists(baseName) && !rejectedCandidates[baseName]) return baseName; for (var suffixNumber = 2; suffixNumber < 10000; suffixNumber++) { var suffix = "-" + suffixNumber; var candidate = baseName.substring(0, 32 - suffix.length).replace(/-+$/, "") + suffix; if (!exists(candidate) && !rejectedCandidates[candidate]) return candidate; } throw new Error("Could not allocate a unique Linux username for " + baseName); } function allocateLinuxUsername(baseName, existsFn, createUser) { var exists = existsFn || linuxUserExists; var rejectedCandidates = Object.create(null); for (var attempt = 0; attempt < 9999; attempt++) { var linuxName = findAvailableLinuxUsername(baseName, exists, rejectedCandidates); try { createUser(linuxName); return linuxName; } catch (e) { var msg = (e.stderr || e.message || "").trim(); if (exists(linuxName) || e.status === 9 || /already exists/i.test(msg)) { // NSS can lag a successful concurrent useradd, so remember collisions locally. rejectedCandidates[linuxName] = true; continue; } throw e; } } throw new Error("Could not allocate a unique Linux username for " + baseName); } /** * Ensure linger is enabled for a Linux user so systemd creates /run/user/. * Required for CLI tools like gcloud and gh that need XDG_RUNTIME_DIR. */ function ensureLinger(username) { try { if (!isSafeLinuxUsername(username)) throw new Error("Invalid Linux username"); var uid = execFileSync("id", ["-u", username], { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim(); var lingerFile = "/var/lib/systemd/linger/" + username; if (fs.existsSync(lingerFile)) return; execFileSync("loginctl", ["enable-linger", username], { encoding: "utf8", timeout: 10000, stdio: "pipe", }); console.log("[os-users] Enabled linger for " + username + " (uid " + uid + ")"); } catch (e) { console.warn("[os-users] Failed to enable linger for " + username + ": " + (e.stderr || e.message || "").trim()); } } /** * Check if a Linux user already exists. */ function linuxUserExists(username) { try { if (!isSafeLinuxUsername(username)) return false; execFileSync("id", [username], { encoding: "utf8", timeout: 5000, stdio: "pipe" }); return true; } catch (e) { return false; } } function getLinuxUserHome(username) { try { if (!isSafeLinuxUsername(username)) return "/home/" + (username || ""); var line = execFileSync("getent", ["passwd", username], { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim(); var parts = line.split(":"); return parts[5] || "/home/" + username; } catch (e) { return "/home/" + username; } } function getLinuxUserUid(username) { try { if (!isSafeLinuxUsername(username)) return null; var uid = execFileSync("id", ["-u", username], { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim(); return parseInt(uid, 10); } catch (e) { return null; } } /** * Check whether the Claude CLI is already resolvable for a Linux user, * using the same login PATH the session will actually run with. * Runs `command -v claude` inside a login shell (`su - `) so a * system-wide install (e.g. /usr/bin/claude) is detected, not just the * hardcoded ~/.local/bin/claude path. * Returns true if claude resolves, false otherwise. */ function defaultRuntimeProvisioning() { return require("./yoke/provisioning").forProvider("claude", { isSafeLinuxUsername: isSafeLinuxUsername, resolveOsUserInfo: resolveOsUserInfo }); } function claudeAvailableForUser(user) { return defaultRuntimeProvisioning().available(user); } function installClaudeCli(user) { return defaultRuntimeProvisioning().install(user); } /** * Provision a Linux user account for a Clay user. * Creates the account via useradd with a home directory. * Returns { ok: true, linuxUser: "clay-xxx" } or { error: "..." }. */ function provisionLinuxUser(clayUsername) { var baseName = toLinuxUsername(clayUsername); var linuxName; try { linuxName = allocateLinuxUsername(baseName, linuxUserExists, function(candidate) { execFileSync("useradd", ["-m", "-s", "/bin/bash", candidate], { encoding: "utf8", timeout: 15000, stdio: "pipe", }); }); } catch (e) { var msg = (e.stderr || e.message || "").trim(); console.error("[os-users] Failed to provision Linux user " + baseName + ": " + msg); return { error: "Failed to create Linux user " + baseName + ": " + msg }; } if (linuxName !== baseName) { console.log("[os-users] Linux user " + baseName + " already exists; provisioning " + linuxName + " instead"); } ensureLinger(linuxName); console.log("[os-users] Provisioned Linux user: " + linuxName + " (Clay user: " + clayUsername + ")"); installClaudeCli(linuxName); return { ok: true, linuxUser: linuxName }; } /** * Provision Linux accounts for all Clay users that don't have one yet. * usersModule: the users.js module (getAllUsers, updateLinuxUser, etc.) * Returns { provisioned: [...], skipped: [...], errors: [...] }. */ function provisionAllUsers(usersModule) { var users = usersModule.getAllUsers(); var result = { provisioned: [], skipped: [], errors: [] }; for (var i = 0; i < users.length; i++) { var user = users[i]; if (user.linuxUser) { // Already mapped, verify the Linux user still exists if (linuxUserExists(user.linuxUser)) { // Ensure Claude CLI is available for existing users. Resolve against // the user's actual login PATH so a system-wide install counts, rather // than checking a single hardcoded ~/.local/bin/claude path. if (!claudeAvailableForUser(user.linuxUser)) { console.log("[os-users] Claude CLI not resolvable for " + user.linuxUser + ", installing..."); installClaudeCli(user.linuxUser); } // Ensure linger is enabled for existing users ensureLinger(user.linuxUser); result.skipped.push({ id: user.id, username: user.username, linuxUser: user.linuxUser }); continue; } // Linux user was deleted externally, re-provision console.log("[os-users] Linux user " + user.linuxUser + " no longer exists, re-provisioning for " + user.username); } var provision = provisionLinuxUser(user.username); if (provision.ok) { // Update the Clay user record with the Linux username var data = usersModule.loadUsers(); for (var j = 0; j < data.users.length; j++) { if (data.users[j].id === user.id) { data.users[j].linuxUser = provision.linuxUser; usersModule.saveUsers(data); break; } } result.provisioned.push({ id: user.id, username: user.username, linuxUser: provision.linuxUser }); } else { result.errors.push({ id: user.id, username: user.username, error: provision.error }); } } return result; } /** * Grant ACL access on a project directory to ALL Clay users with linuxUser mappings. * Used when a project becomes public. * usersModule: the users.js module (getAllUsers, etc.) */ function grantAllUsersAccess(projectPath, usersModule) { var allUsers = usersModule.getAllUsers(); for (var i = 0; i < allUsers.length; i++) { if (allUsers[i].linuxUser) { grantProjectAccess(projectPath, allUsers[i].linuxUser); } } } /** * Deactivate (lock) a Linux user account. * The account and home directory are preserved, but login is disabled. */ function deactivateLinuxUser(linuxUsername) { try { if (!isSafeLinuxUsername(linuxUsername)) throw new Error("Invalid Linux username"); execFileSync("usermod", ["-L", linuxUsername], { encoding: "utf8", timeout: 5000, stdio: "pipe" }); console.log("[os-users] Deactivated Linux user: " + linuxUsername); return { ok: true }; } catch (e) { var msg = (e.stderr || e.message || "").trim(); console.error("[os-users] Failed to deactivate Linux user " + linuxUsername + ": " + msg); return { error: "Failed to deactivate Linux user " + linuxUsername + ": " + msg }; } } /** * Ensure the shared projects directory exists. */ function ensureProjectsDir() { var dir = "/var/clay/projects"; if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o711 }); } else { // Tighten permissions if directory already exists (prevent listing by other users) try { fs.chmodSync(dir, 0o711); } catch (e) {} } } module.exports = { checkAclSupport: checkAclSupport, resolveOsUserInfo: resolveOsUserInfo, fsAsUser: fsAsUser, grantProjectAccess: grantProjectAccess, revokeProjectAccess: revokeProjectAccess, toLinuxUsername: toLinuxUsername, linuxUserExists: linuxUserExists, provisionLinuxUser: provisionLinuxUser, provisionAllUsers: provisionAllUsers, grantAllUsersAccess: grantAllUsersAccess, installClaudeCli: installClaudeCli, claudeAvailableForUser: claudeAvailableForUser, wrapSpawnAsUser: wrapSpawnAsUser, deactivateLinuxUser: deactivateLinuxUser, ensureProjectsDir: ensureProjectsDir, isHomeDirectory: isHomeDirectory, getLinuxUserHome: getLinuxUserHome, getLinuxUserUid: getLinuxUserUid, isSafeLinuxUsername: isSafeLinuxUsername, findAvailableLinuxUsername: findAvailableLinuxUsername, allocateLinuxUsername: allocateLinuxUsername, };