/** * @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", "delete"]); 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 }; // The last server.conf that read well, per path. A server that started with // S3 keeps it if the file later fails to read (a transient error, an edit in // progress): falling back to "no S3" would run a member's pane as the HOST // account in their folder. Removing S3 for good is followed by a restart. let lastGood = { 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; let value = readConf(file); if (value) lastGood = { path: file, value }; else if (lastGood.path === file && require("./auth").isServerMode()) value = lastGood.value; confCache = { at: Date.now(), path: file, value }; return confCache.value; } let TMUX_MEMBER = "/opt/agenthub/libexec/tmux-member"; /** Test seam: where tmux-member is looked for. */ function __setTmuxMember(p) { TMUX_MEMBER = p; confCache = { at: 0, path: null, value: null }; } 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; // A member's tmux runs through tmux-member once install-system put it there // (and in sudoers): it picks their RUNNING server's own binary, so a tmux // upgrade never mixes a new client with an old server. Before that, TMUX_BIN. let tmuxBin = get("TMUX_BIN"); try { fs.accessSync(TMUX_MEMBER, fs.constants.X_OK); if (path.isAbsolute(tmuxBin)) tmuxBin = TMUX_MEMBER; } catch { /* not installed yet */ } return { hostUser: get("HOST_USER"), hostUid: Number(get("HOST_UID")), usersRoot, port: Number(get("PORT")) || null, // The installed tmux (a new member server starts on it). tmuxInstalled: path.isAbsolute(get("TMUX_BIN")) ? get("TMUX_BIN") : 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", tmuxBin], ["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; } /** * A member's folder (under this machine's S3 users root) while THIS server * runs without S3 — a local-mode or dev dashboard on the same database. Its * panes live on the member's own tmux server, which such a server cannot * ask, so it must never judge them dead. */ function unseenMemberFolder(cwd) { if (typeof cwd !== "string" || !cwd || s3Enabled()) return false; const conf = serverConf(); if (!conf) return false; const rel = path.relative(conf.usersRoot, path.resolve(cwd)); return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel); } 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 / delete (account AND home) 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(); // An installed helper older than this build (root's copy is refreshed // only by install-system) does not know the action yet. const hint = /unknown action/.test(why) ? " — the server helper is older than this AgentHub: run `sudo agenthub server install-system` once" : ""; throw Object.assign(new Error(`could not ${action} the Linux account: ${why}${hint}`), { code: "EPROVISION", }); } } function __resetConfCache() { confCache = { at: 0, path: null, value: null }; lastGood = { path: null, value: null }; } module.exports = { __resetConfCache, __setTmuxMember, confPath, LINUX_NAME_RE, serverConf, s3Enabled, unseenMemberFolder, accountFor, assertLinuxName, provision, loginFor, __setRunImpl, };