943ba03b76
- self-restart: a server in our unit's cgroup (/agenthub.service, system or user) restarts via `systemctl restart --no-block` (sudo -n for the system unit), our launchd job by exiting into KeepAlive. The detached restart-helper replacement held the port while Restart=always started the unit every 3 s (v2.7.2 on the S3 box: 240+ restarts, MainPID 0). - run-as.mayHaveTmux: only ENOENT means "no tmux". A dev server in server mode without CAP_DAC_READ_SEARCH got EACCES on a member's 0700 .agenthub/tmux, dropped the member from the list and ended their runs within seconds — the real cause of `401 not a live pane's token`. - tmux: a member's list is re-asked every 5 s, not 1 s (one sudo + three auth.log lines each). - POST /api/lanes/:id/start: 409 ENOCWD for a folder that no longer exists (the pane started in the account's home otherwise). - Settings: Remote sources only for staff (a developer got a 403). - scripts/test-server.js drops every inherited AGENTHUB_* (pane env). Tests: self-restart.test.js, server-s3-panes (mayHaveTmux), lanes-api (ENOCWD); touched suites 771 + client pages 191, tsc -b.
546 lines
20 KiB
JavaScript
546 lines
20 KiB
JavaScript
/**
|
|
* @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 `<USERS_ROOT>/<name>/` belongs to the
|
|
* AgentHub user `<name>` (Linux `ah-<name>`), 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-<name> [VAR=value…] <command> …`; 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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-<id>[-<slot>]` → 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-<name>`), 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 <login> [VAR=value…] <command> <args…>` — 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 <file> [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 */
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 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 <file>`, 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");
|
|
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,
|
|
MEMBER_PATH,
|
|
writeFileAs,
|
|
scratchFile,
|
|
accountForUser,
|
|
handoffWrite,
|
|
writeAs,
|
|
mkdirAs,
|
|
removeAs,
|
|
launchArgv,
|
|
sameOwner,
|
|
openSafe,
|
|
readFileSafe,
|
|
openSafeAsync,
|
|
readFileSafeAsync,
|
|
readStreamSafe,
|
|
readLines,
|
|
isMemberFile,
|
|
commandIn,
|
|
execIn,
|
|
__setExecImpl,
|
|
__resetCache,
|
|
};
|