/** * @file run-as.js * @description Server mode, one Linux user per account (S3 plan, P3): which * Linux account a pane, a git call or a file write must run as, and the argv * that does it. * * A path decides it: everything under `//` belongs to the * AgentHub user `` (Linux `ah-`), the same folder rule * `scope.folderAllowed` keeps a member's workspaces in. A pane is decided by * its workspace's folder. Anything else — local mode, S3 not installed, a * folder outside the users root — is `null`: the host account runs it, * exactly as before. A folder under the users root whose user is unknown, * disabled or not provisioned is an ERROR (`EMEMBERGONE`), never "the host * then": the host account must not run anything in a member's home. * * The argv is always `sudo -n -u ah- [VAR=value…] …`; the * sudoers file install-system wrote allows only a fixed command list for * `%agenthub-members`, and `SETENV` for the variables. The dashboard never * writes, chmods or deletes inside a member's home itself — the member can * swap any name there, and the host's write would follow the swap — so every * such change runs AS the member through `write-as`. * @author Nguyễn Ngọc Trí Vĩ */ const path = require("node:path"); const fs = require("node:fs"); const linux = require("./linux-accounts"); const gone = (name) => Object.assign(new Error(`${name}: no enabled, provisioned account owns this folder`), { code: "EMEMBERGONE", }); /** * The account owning `p`, or null (host). `{name, login, home, uid}`. * @param {string} p an absolute path * @throws EMEMBERGONE for a folder under the users root no usable account owns */ function accountForPath(p) { if (typeof p !== "string" || !path.isAbsolute(p) || !linux.s3Enabled()) return null; const conf = linux.serverConf(); const rel = path.relative(conf.usersRoot, path.resolve(p)); if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null; const name = rel.split(path.sep)[0]; if (!linux.LINUX_NAME_RE.test(name)) throw gone(name); const { db } = require("../db"); const user = db.prepare("SELECT disabled FROM users WHERE username = ?").get(name); if (!user || user.disabled) throw gone(name); const login = linux.loginFor(name); const uid = uidOf(login); if (uid == null) throw gone(name); // not provisioned yet: nothing to run as return { name, login, home: path.join(conf.usersRoot, name), uid }; } /** `accountForPath` for a READ: a gone member's file is still theirs, never the host's. */ function ownerForRead(p) { try { return accountForPath(p); } catch { const conf = linux.serverConf(); const name = path.relative(conf.usersRoot, path.resolve(p)).split(path.sep)[0]; const uid = uidOf(linux.loginFor(name)); // No uid at all: nobody's file may be read as theirs (-1 matches no owner). return { name, login: linux.loginFor(name), home: path.join(conf.usersRoot, name), uid: uid ?? -1, }; } } /** The account a workspace's panes run as (by its folder), or null. */ function accountForLane(laneId) { if (!linux.s3Enabled()) return null; // no DB read outside S3 const lane = require("./lanes").getLane(Number(laneId)); return lane ? accountForPath(lane.cwd) : null; } /** * S3: a workspace's panes run as the account of its FOLDER and carry its * OWNER's pane token (and personal AI Proxy), so the two must agree. In a * member's folder only that member may own it — any other owner would hand * their API identity to the folder's member. A folder outside the users root * runs as the HOST account, so only an admin (by the LIVE role) may own one: * a developer's or analyst's pane there would be a shell as the host. No * owner (a workspace from before server mode) is not this rule's business. * @throws EOWNERFOLDER */ function assertOwnerFits(cwd, ownerId) { if (ownerId == null || !linux.s3Enabled()) return; const name = ownerForRead(cwd)?.name; const { db } = require("../db"); const owner = db.prepare("SELECT username, role FROM users WHERE id = ?").get(ownerId); if (name ? owner?.username === name : owner?.role === "admin") return; throw Object.assign( new Error( name ? `${cwd} is in ${name}'s home: only ${name} may own a workspace there` : `${cwd} runs as the host account: only an admin may own a workspace there` ), { code: "EOWNERFOLDER" } ); } /** `agenthub-lane-[-]` → its workspace's account, or null. */ function accountForSession(name) { if (!linux.s3Enabled()) return null; // every tmux call asks: no DB read outside S3 const m = /^agenthub-lane-(\d+)(?:-\d)?$/.exec(String(name || "")); return m ? accountForLane(Number(m[1])) : null; } /** The member's tmux socket (only ever reached AS the member, through sudo). */ const tmuxSocket = (acct) => path.join(acct.home, ".agenthub", "tmux", "sock"); /** Where the files a member's pane reads are written (as the member, `write-as`). */ const handoffDir = (acct) => path.join(acct.home, ".agenthub", "handoff"); /** The home of every enabled, provisioned member (S3), else []. */ function memberHomes() { if (!linux.s3Enabled()) return []; const conf = linux.serverConf(); const { db } = require("../db"); return db .prepare("SELECT username FROM users WHERE disabled = 0") .all() .map((u) => u.username) .filter((n) => linux.LINUX_NAME_RE.test(n) && uidOf(linux.loginFor(n)) != null) .map((n) => path.join(conf.usersRoot, n)); } /** Every provisioned member whose tmux socket exists — for listing all panes. */ function membersWithTmux() { return memberHomes() .map((home) => accountForPath(home)) .filter(mayHaveTmux); } /** * Whether the member may have a tmux server. Only a missing socket says no: * a member's `.agenthub/tmux` is theirs (0700), and a dashboard without * CAP_DAC_READ_SEARCH (a dev server on the same database) gets EACCES there * — that proves nothing, and reading it as "no server" ended every run of * theirs within seconds. Their list is asked through sudo, which can tell. */ function mayHaveTmux(acct) { try { return fs.statSync(tmuxSocket(acct)).isSocket(); } catch (err) { return err.code !== "ENOENT" && err.code !== "ENOTDIR"; } } // uid by login, from /etc/passwd (world-readable; node has no getpwnam). let passwdCache = { at: 0, byLogin: new Map(), byUid: new Map() }; function passwd() { if (Date.now() - passwdCache.at < 30_000) return passwdCache; const byLogin = new Map(); const byUid = new Map(); try { const file = process.env.AGENTHUB_PASSWD || "/etc/passwd"; // a test's own for (const line of fs.readFileSync(file, "utf8").split("\n")) { const [login, , uid] = line.split(":"); if (login && /^\d+$/.test(uid || "")) { byLogin.set(login, Number(uid)); byUid.set(Number(uid), login); } } } catch { /* no passwd: nobody is a member */ } passwdCache = { at: Date.now(), byLogin, byUid }; return passwdCache; } const uidOf = (login) => passwd().byLogin.get(login) ?? null; /** Does AgentHub user `username` have a Linux account (a fitting name in passwd)? */ const hasLinuxAccount = (username) => linux.LINUX_NAME_RE.test(String(username || "")) && uidOf(linux.loginFor(username)) != null; /** The AgentHub username behind a Linux uid (`ah-`), or null. */ function memberNameForUid(uid) { const login = passwd().byUid.get(Number(uid)); const m = login ? /^ah-([a-z][a-z0-9_-]{0,28})$/.exec(login) : null; return m ? m[1] : null; } /** A member pane's PATH: the shared tools (`/opt/agenthub/tools/bin`) first. */ const MEMBER_PATH = "/opt/agenthub/tools/bin:/usr/local/bin:/usr/bin:/bin"; /** The host's desktop session — a member's pane has none of it. */ const HOST_ONLY = [ "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", ]; /** * A member pane's environment, over the one a host pane would get: the * member's own home and identity (so Claude/Codex keep their config, logins * and transcripts there), the shared tools first on PATH, self-updates off * (the binaries are shared), the dashboard's port for hooks and the CLI, and * none of the host's desktop. */ function memberPaneEnv(acct, base, { codex = false } = {}) { const env = { ...base }; for (const k of HOST_ONLY) delete env[k]; Object.assign(env, { HOME: acct.home, USER: acct.login, LOGNAME: acct.login, SHELL: "/bin/bash", PATH: MEMBER_PATH, DISABLE_AUTOUPDATER: "1", CLAUDE_DASHBOARD_PORT: String(require("./proxy-gateway").listenPort()), }); if (codex) env.CODEX_HOME = path.join(acct.home, ".codex"); return env; } /** * `sudo -n -u [VAR=value…] ` — the sudoers file allows * exactly the commands install-system listed, by absolute path (server.conf * records the ones it listed), with `SETENV` for `env`. */ function sudoArgv(acct, command, args = [], env = {}) { const assigns = Object.entries(env).map(([k, v]) => { // A key with `=` or a leading `-` would be read by sudo as something else. if (!/^[A-Z_][A-Z0-9_]*$/.test(k)) throw new Error(`sudoArgv: bad variable name ${k}`); return `${k}=${v}`; }); const bin = linux.serverConf()?.bins?.[command] || command; return ["sudo", ["-n", "-u", acct.login, ...assigns, bin, ...args]]; } const WRITE_AS = "/opt/agenthub/libexec/write-as"; // Test seam: how a command runs as a member (sync, with stdin). let execImpl = (cmd, argv, opts) => require("node:child_process").execFileSync(cmd, argv, opts); function __setExecImpl(fn) { execImpl = fn; } // Every synchronous call that runs something AS a member is bounded: it // blocks the event loop, and the member can leave a FIFO where write-as // writes or where their git reads its config (`include.path`). SIGKILL: the // command may ignore anything gentler. const MEMBER_EXEC = { timeout: 5000, killSignal: "SIGKILL" }; /** Run `write-as [mode]` as `acct`, `content` on stdin. */ function writeAs(acct, file, mode, content = "") { const [cmd, argv] = sudoArgv(acct, WRITE_AS, mode ? [file, mode] : [file]); execImpl(cmd, argv, { ...MEMBER_EXEC, input: content, stdio: ["pipe", "ignore", "pipe"] }); } /** * Save `content` to `file` — as the member who owns the folder (S3), else * directly. In place, keeping the file's mode; `append` adds to the end. */ function writeFileAs(file, content, { append = false } = {}) { const acct = accountForPath(file); if (!acct) { if (append) fs.appendFileSync(file, content); else fs.writeFileSync(file, content); return; } writeAs(acct, file, append ? "append" : "", content); } /** * A scratch FILE name a git command in `cwd` may create (a temporary index): * in the host's tmp, or — for a member's folder — a fresh name in the * member's handoff dir that the member's own git creates. `remove()` deletes * it (and git's `.lock`) as whoever owns it. */ function scratchFile(cwd, prefix) { const name = `${prefix}${require("node:crypto").randomBytes(8).toString("hex")}`; const acct = accountForPath(cwd); if (!acct) { const dir = fs.mkdtempSync(path.join(require("node:os").tmpdir(), prefix)); return { file: path.join(dir, name), remove: () => fs.rmSync(dir, { recursive: true, force: true }), }; } const file = path.join(handoffDir(acct), name); return { file, remove: () => { for (const f of [file, `${file}.lock`]) { try { writeAs(acct, f, "remove"); } catch { /* best effort: a stray scratch file is the member's own */ } } }, }; } /** * Mark a member's Claude Code as onboarded (`hasCompletedOnboarding: true` in * `~/.claude.json`), so a pane that brings its own credentials — a pool * account's `CLAUDE_CODE_OAUTH_TOKEN`, an AI Proxy — starts at the prompt * instead of the first-run "Select login method" screen, which a fresh home * shows even with the token set. Only that key — plus, for a pool login, * `extra` (its `oauthAccount`/`userID`, so Claude Code names the account it * runs as) — is added, AS the member (`write-as private`); a file that is not * the member's own regular file or not JSON is left alone. Best effort: false * when nothing was written. */ function markClaudeOnboarded(acct, extra = {}) { const file = path.join(acct.home, ".claude.json"); let state = {}; try { state = JSON.parse(readFileSafe(file, "utf8")); } catch (e) { if (e.code !== "ENOENT") return false; } if (!state || typeof state !== "object" || Array.isArray(state)) return false; const next = { ...state, ...extra, hasCompletedOnboarding: true }; if (JSON.stringify(next) === JSON.stringify(state)) return false; try { writeAs(acct, file, "private", `${JSON.stringify(next, null, 2)}\n`); return true; } catch { return false; } } /** A user's account by AgentHub username (S3, provisioned), or null. */ function accountForUser(username) { const conf = linux.s3Enabled() ? linux.serverConf() : null; if (!conf || !linux.LINUX_NAME_RE.test(String(username || ""))) return null; return accountForPath(path.join(conf.usersRoot, username)); } /** * Write `content` as a file a member's pane reads, in their handoff dir, AS * the member (`replace`: a new 0644 file renamed over the name; `private`: * 0600). Returns the path. */ function handoffWrite(acct, name, content, mode = "replace") { const file = path.join(handoffDir(acct), name); writeAs(acct, file, mode, content); return file; } const PANE_LAUNCH = "/opt/agenthub/libexec/pane-launch"; /** * The argv a member's tmux session starts with: `pane-launch `, where * the file (written AS the member, 0600, deleted by pane-launch as it starts) * holds the pane's environment and command — sudo logs the session's command * line, and these carry a pane token, a pool account's token and the prompt. * NUL-separated: the number of variables, `NAME=value` each, then argv. */ function launchArgv(acct, env = {}, argv = []) { const vars = Object.entries(env || {}).map(([k, v]) => `${k}=${v}`); const body = [String(vars.length), ...vars, ...argv].map((x) => `${x}\0`).join(""); const name = `launch-${require("node:crypto").randomBytes(12).toString("hex")}`; return [PANE_LAUNCH, handoffWrite(acct, name, body, "private")]; } // ── Reading a member's files ─────────────────────────────────────────────── // The dashboard reads members' files as itself, with CAP_DAC_READ_SEARCH (it // must: Claude writes transcripts 0600) — so a name the member swapped for a // link to another member's file, the host's or root's would be read too. A // path in a member's home is therefore opened FIRST and kept only when the // open file is a regular file that member owns (`fstat` on the descriptor: // no window to swap it after the check). O_NONBLOCK: a FIFO cannot hang the // event loop on open. Anything else is plain `fs`, as before. const foreign = (file) => Object.assign(new Error(`${file}: not a file of this member's`), { code: "EFOREIGNFILE" }); const OPEN_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0); function memberOwnerOf(file) { return typeof file === "string" && linux.s3Enabled() ? ownerForRead(file) : null; } /** `fs.openSync(file, "r")`, guarded (above). */ function openSafe(file) { const acct = memberOwnerOf(file); if (!acct) return fs.openSync(file, "r"); const fd = fs.openSync(file, OPEN_FLAGS); const st = fs.fstatSync(fd); if (!st.isFile() || st.uid !== acct.uid) { fs.closeSync(fd); throw foreign(file); } return fd; } // A member's file is read only up to a cap (theirs to make 1.5 GB, sparse, // at no cost): MEMBER_READ_MAX unless the caller passes its own `max`, and // never past the size `fstat` gave — a file growing under the read is cut. const MEMBER_READ_MAX = 64 * 1024 * 1024; const tooBig = (file) => Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" }); /** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */ function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) { if (!memberOwnerOf(file)) return fs.readFileSync(file, enc); const fd = openSafe(file); try { const size = fs.fstatSync(fd).size; if (size > max) throw tooBig(file); const buf = Buffer.alloc(size); let off = 0; for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0;) off += n; return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off); } finally { fs.closeSync(fd); } } /** `fs.promises.open(file, "r")`, guarded. */ async function openSafeAsync(file) { const acct = memberOwnerOf(file); if (!acct) return fs.promises.open(file, "r"); const fh = await fs.promises.open(file, OPEN_FLAGS); const st = await fh.stat(); if (!st.isFile() || st.uid !== acct.uid) { await fh.close(); throw foreign(file); } return fh; } /** `fs.promises.readFile(file, enc)`, guarded (a member's file: ≤ `max` bytes). */ async function readFileSafeAsync(file, enc, { max = MEMBER_READ_MAX } = {}) { if (!memberOwnerOf(file)) return fs.promises.readFile(file, enc); const fh = await openSafeAsync(file); try { const { size } = await fh.stat(); if (size > max) throw tooBig(file); const buf = Buffer.alloc(size); let off = 0; for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0;) off += n; return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off); } finally { await fh.close(); } } /** `fs.createReadStream(file, opts)`, guarded (throws at once, like a missing file would on open). */ function readStreamSafe(file, opts = {}) { if (!memberOwnerOf(file)) return fs.createReadStream(file, opts); return fs.createReadStream(file, { ...opts, fd: openSafe(file) }); } /** * The lines of `file` (opened like `openSafeAsync`), read in 1 MiB chunks. * A line longer than `maxLine` bytes is never built: it comes out as "", so * line numbers stay right. readline builds every line whole, and one 512 MB * line (a member's sparse file costs them nothing) throws a RangeError inside * the stream that no try/catch around the loop catches — the server exits. * A trailing "\r" is dropped, like readline's `crlfDelay: Infinity`. */ async function* readLines(file, { maxLine = 64 * 1024 * 1024 } = {}) { const fh = await openSafeAsync(file); const text = (b) => { const line = b.toString("utf8"); return line.endsWith("\r") ? line.slice(0, -1) : line; }; try { const buf = Buffer.allocUnsafe(1 << 20); let parts = []; let partLen = 0; let skipping = false; // inside a line already over maxLine for (;;) { const { bytesRead } = await fh.read(buf, 0, buf.length, null); if (!bytesRead) break; const view = buf.subarray(0, bytesRead); let start = 0; for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) { if (skipping || partLen + (i - start) > maxLine) yield ""; else yield text( partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i) ); parts = []; partLen = 0; skipping = false; start = i + 1; } if (start < bytesRead && !skipping) { if (partLen + (bytesRead - start) > maxLine) { skipping = true; parts = []; partLen = 0; } else { parts.push(Buffer.from(view.subarray(start))); partLen += bytesRead - start; } } } if (skipping) yield ""; else if (partLen) yield text(Buffer.concat(parts)); } finally { await fh.close(); } } /** Whether `file` is a member's (S3): read with that member's caps. */ const isMemberFile = (file) => Boolean(memberOwnerOf(file)); /** * Whether every path given belongs to one account (S3; always true outside * it) — a member's journal speaks only for that member's folders and rows. */ function sameOwner(...paths) { if (!linux.s3Enabled()) return true; const keys = paths.filter(Boolean).map((p) => ownerForRead(p)?.name || ""); return keys.every((k) => k === keys[0]); } /** `mkdir -p` / `rm -rf` of a path in a member's home, AS the member. */ const mkdirAs = (acct, dir) => writeAs(acct, dir, "mkdir"); /** * S3: make a missing workspace folder inside a member's own home, AS them * (`mkdir -p`, so only where they could themselves). A new member has no * other way to their first project: their home is no workspace (EFOLDER). * False — nothing made — for any other folder, the home itself, or a member * with no usable account. */ function makeMemberFolder(dir) { let acct; try { acct = accountForPath(dir); } catch { return false; } const abs = path.resolve(dir); if (!acct || abs === acct.home) return false; mkdirAs(acct, abs); return true; } const removeAs = (acct, p) => writeAs(acct, p, "remove"); /** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */ function commandIn(cwd, command, args, env = {}) { const acct = accountForPath(cwd); return acct ? sudoArgv(acct, command, args, { HOME: acct.home, ...env }) : [command, args]; } /** Run `command args` in `cwd` (sync): as its member under S3, else as is. */ function execIn(cwd, command, args, opts = {}) { const [cmd, argv] = commandIn(cwd, command, args); return execImpl(cmd, argv, { ...MEMBER_EXEC, cwd, ...opts }); } function __resetCache() { passwdCache = { at: 0, byLogin: new Map(), byUid: new Map() }; } module.exports = { accountForPath, accountForLane, accountForSession, assertOwnerFits, hasLinuxAccount, membersWithTmux, mayHaveTmux, memberHomes, ownerForRead, tmuxSocket, handoffDir, memberNameForUid, sudoArgv, memberPaneEnv, markClaudeOnboarded, MEMBER_PATH, writeFileAs, scratchFile, accountForUser, handoffWrite, writeAs, mkdirAs, makeMemberFolder, removeAs, launchArgv, sameOwner, openSafe, readFileSafe, openSafeAsync, readFileSafeAsync, readStreamSafe, readLines, isMemberFile, commandIn, execIn, __setExecImpl, __resetCache, };