/** * @file linux-accounts.js * @description Server mode, one Linux user per account (S3 plan, P2). Each * AgentHub user `alice` maps to the Linux user `ah-alice`, whose home is * `/alice` — the same folder `scope.folderAllowed` keeps her * workspaces in. * * S3 is ON when server mode is on AND `/etc/agenthub/server.conf` exists * (written by `sudo agenthub server install-system`, root-owned). Without it * everything here is a no-op and server mode behaves as in S2. * * `provision` is the dashboard's only way to create, disable or enable a Linux * account: `sudo -n /opt/agenthub/sbin/agenthub-provision `, * the one root command sudoers lets the host run. Only an action from a fixed * set and a name that passed `LINUX_NAME_RE` ever reach its argv. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); /** Lower case, a letter first, ≤ 29 so `ah-` fits Linux's 32. */ const LINUX_NAME_RE = /^[a-z][a-z0-9_-]{0,28}$/; const PROVISION = "/opt/agenthub/sbin/agenthub-provision"; const ACTIONS = new Set(["create", "disable", "enable"]); const confPath = () => process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server.conf"; // server.conf is read on every tmux call's account lookup — keep it 5 s. let confCache = { at: 0, path: null, value: null }; /** `{hostUser, hostUid, usersRoot, port, bins}` from server.conf, or null. */ function serverConf() { const file = confPath(); if (confCache.path === file && Date.now() - confCache.at < 5000) return confCache.value; confCache = { at: Date.now(), path: file, value: readConf(file) }; return confCache.value; } function readConf(file) { let text; try { text = fs.readFileSync(file, "utf8"); } catch { return null; } const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || ""; const usersRoot = get("USERS_ROOT"); if (!path.isAbsolute(usersRoot)) return null; return { hostUser: get("HOST_USER"), hostUid: Number(get("HOST_UID")), usersRoot, port: Number(get("PORT")) || null, // The absolute paths sudoers lists (sudo would resolve a bare name on its // own secure_path, which may not be them). bins: Object.fromEntries( [ ["tmux", get("TMUX_BIN")], ["git", get("GIT_BIN")], ["gh", get("GH_BIN")], ].filter(([, v]) => path.isAbsolute(v)) ), }; } /** Is S3 on: server mode plus an installed system layout. */ function s3Enabled() { return require("./auth").isServerMode() && serverConf() !== null; } const loginFor = (username) => `ah-${username}`; /** The Linux side of an AgentHub user, or null outside S3. */ function accountFor(username) { const conf = s3Enabled() ? serverConf() : null; if (!conf || !LINUX_NAME_RE.test(String(username || ""))) return null; return { login: loginFor(username), home: path.join(conf.usersRoot, username) }; } /** Throws EBADUSER unless `username` can be a Linux account (S3 only). */ function assertLinuxName(username) { if (s3Enabled() && !LINUX_NAME_RE.test(String(username || ""))) { throw Object.assign( new Error( "username: with one Linux user per account it must be lower case — a letter, then up to 28 letters, digits, '_' or '-'" ), { code: "EBADUSER" } ); } } // Test seam: the sudo call itself. let runImpl = (argv) => execFileSync("sudo", ["-n", ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); function __setRunImpl(fn) { runImpl = fn; } /** * Create / disable / enable the Linux account of `username`. A no-op outside * S3 (returns null). Throws EPROVISION with the helper's own words on failure. */ function provision(action, username) { if (!s3Enabled()) return null; if (!ACTIONS.has(action)) throw new Error(`unknown provision action: ${action}`); assertLinuxName(username); try { return String(runImpl([PROVISION, action, username]) || "").trim(); } catch (e) { const why = String(e.stderr || e.message || "") .trim() .split("\n") .pop(); throw Object.assign(new Error(`could not ${action} the Linux account: ${why}`), { code: "EPROVISION", }); } } function __resetConfCache() { confCache = { at: 0, path: null, value: null }; } module.exports = { __resetConfCache, confPath, LINUX_NAME_RE, serverConf, s3Enabled, accountFor, assertLinuxName, provision, loginFor, __setRunImpl, };