lib/os-users.js
Rama main · réplica de lectura del repositorio original.
// 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/<uid>.
* 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 - <user>`) 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,
};