Files
nntrivi2001 e94038481e feat: v2.7.7 — File Explorer on workspaces and the user's home, clone with per-user git identity
- File Explorer (Files in the sidebar, /files; also from a workspace's Files
  tab, portalled so no panel clips it), laid out like GNOME Files: places
  (Home, workspaces, Trash), Back/Forward/Up, path bar, search, icon grid with
  image thumbnails or list, labelled New/Upload, selection action bar, hidden
  files behind Ctrl+H, right-click menus and the usual keys.
  Make, rename, move, copy (within a workspace, between workspaces, home ↔
  workspace), delete into a per-account trash restorable for 7 days, streamed
  upload of files and whole folders (≤ 500 MB a batch, Replace / Keep both /
  Skip on a taken name), download a file or a zip of anything.
  server/lib/file-explorer.js, /api/lanes/:id/fs/*, /api/homes/:who/fs/*.
- The user's home: every dot entry at its top stays hidden and refused
  (also via a symlink, download, zip or as a copy source); an admin may view
  other users' homes read-only, audited.
- write-as learns move/copy (re-run `sudo agenthub server install-system`).
- Hardening: a write to a workspace re-checks its owner by the live role;
  S3 keeps its server.conf if the file stops reading and a tmux call never
  falls back to the host's server; the AI Proxy gateway marks what it relays
  and the dashboard refuses it; a NUL in a pane's argv/env is refused.
- OpenAPI for the new routes (server/openapi-extra/files.js); docs (API,
  SERVER-MODE, CLAUDE.md) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:17:39 +07:00

655 lines
24 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"] });
}
// Test seam: how an ASYNC write-as runs (spawn-like: returns a child).
let spawnImpl = (cmd, argv, opts) => require("node:child_process").spawn(cmd, argv, opts);
function __setSpawnImpl(fn) {
spawnImpl = fn || ((cmd, argv, opts) => require("node:child_process").spawn(cmd, argv, opts));
}
/**
* `write-as <file> <args…>` as `acct`, without blocking the event loop — for
* what may be big or slow (an upload streamed from the request, a recursive
* copy, a move across filesystems). `input`: a Buffer/string, or a readable
* stream piped to stdin (never argv: sudo logs it). Killed after `timeout`.
*/
function writeAsAsync(acct, file, args = [], { input = null, timeout = 10 * 60_000 } = {}) {
const [cmd, argv] = sudoArgv(acct, WRITE_AS, [file, ...args]);
return new Promise((resolve, reject) => {
const child = spawnImpl(cmd, argv, { stdio: ["pipe", "ignore", "pipe"] });
let stderr = "";
child.stderr?.on("data", (d) => (stderr += d));
const timer = setTimeout(() => child.kill("SIGKILL"), timeout);
child.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0) return resolve();
reject(
Object.assign(new Error(stderr.trim() || `write-as exited with ${code}`), {
code: "EWRITEAS",
})
);
});
child.stdin.on("error", () => {});
if (input && typeof input.pipe === "function") input.pipe(child.stdin);
else child.stdin.end(input ?? "");
});
}
/** Whether the installed write-as knows `move`/`copy` (install-system re-run). */
function writeAsCanMove() {
try {
return /^\s*move\)/m.test(fs.readFileSync(WRITE_AS, "utf8"));
} catch {
return false;
}
}
/**
* 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 <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}`);
// The file is NUL-separated: a NUL inside a value (a prompt) would split it
// into extra argv elements — flags the request never asked for.
if ([...vars, ...argv].some((x) => String(x).includes("\0"))) {
throw Object.assign(new Error("a NUL byte in a pane's argv or environment"), {
code: "EBADARG",
});
}
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,
writeAsAsync,
writeAsCanMove,
__setSpawnImpl,
mkdirAs,
makeMemberFolder,
removeAs,
launchArgv,
sameOwner,
openSafe,
readFileSafe,
openSafeAsync,
readFileSafeAsync,
readStreamSafe,
readLines,
isMemberFile,
commandIn,
execIn,
__setExecImpl,
__resetCache,
};