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>
This commit is contained in:
+14
-2
@@ -6,7 +6,8 @@
|
||||
* it, whose it was and which path. `auditGuard` runs on `/api` after the
|
||||
* scope guard; it never blocks a request, and it is a no-op in local mode and
|
||||
* for a member (who can only read their own). An admin's account change on
|
||||
* the Users page is written here too (`recordAccountChange`).
|
||||
* the Users page is written here too (`recordAccountChange`), and so is an
|
||||
* admin's look into another user's home (`recordHomeRead`, File Explorer).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -68,4 +69,15 @@ function recordAccountChange(actorId, targetId, method, action) {
|
||||
).run(actorId, targetId, method, String(action).slice(0, 500));
|
||||
}
|
||||
|
||||
module.exports = { auditGuard, listAudit, recordAccountChange };
|
||||
/** An admin opening another user's home in the File Explorer (read-only). */
|
||||
function recordHomeRead(actorId, ownerId, method, what) {
|
||||
try {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
|
||||
).run(actorId, ownerId, method, String(what).slice(0, 500));
|
||||
} catch (err) {
|
||||
console.warn("[audit] could not record:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { auditGuard, listAudit, recordAccountChange, recordHomeRead };
|
||||
|
||||
@@ -0,0 +1,811 @@
|
||||
/**
|
||||
* @file The workspace File Explorer (`/api/lanes/:id/fs/*`): list a folder,
|
||||
* make a folder or an empty file, rename, move and copy (within a workspace
|
||||
* or into another of the same account), delete into a per-account TRASH that
|
||||
* can be restored (kept TRASH_DAYS), upload a file streamed from the request
|
||||
* into any (new) sub-folder, and download files or folders as one streamed zip.
|
||||
*
|
||||
* Every path is the read path's own (git-diff.js): `lanePath` (lexical, no
|
||||
* `..`), the PARENT resolved by `resolveInLane` (realpath-confined, so no
|
||||
* symlink walks out) and the last segment kept as a name — so an operation on
|
||||
* a symlink acts on the link itself, never on what it points to — and no
|
||||
* secret (`settings.local.json`, a member's dot-dirs) by name or by what it
|
||||
* resolves to. Nothing is created, moved or deleted in or under `.git` (a
|
||||
* hook there runs the next time the dashboard runs git), and the workspace
|
||||
* folder itself is never moved or deleted.
|
||||
*
|
||||
* Writes run as the folder's ACCOUNT (run-as.js): a member's through
|
||||
* `write-as` (sudo, as them — the host never writes in their home), the
|
||||
* host's directly. A move/copy/trash in a member's folder needs write-as's
|
||||
* `move`/`copy` (`EUPGRADE` until `sudo agenthub server install-system` has
|
||||
* installed it). A copy or move never crosses accounts (`EFOREIGN`).
|
||||
*
|
||||
* A name that is taken is never silently replaced: `conflict` decides —
|
||||
* `fail` (default, 409 EEXISTS with the names), `skip`, `keep` (a free
|
||||
* "name (1).ext") or `replace` (the old one goes to the trash first).
|
||||
*
|
||||
* A user's HOME is browsed through the same functions, run inside `inHome`:
|
||||
* there every dot entry at the top of the home (`.ssh`, `.claude`, `.config`,
|
||||
* `.agenthub`…, where logins and pane tokens live) is hidden from listings and
|
||||
* refused for every read and write — also when reached through a symlink —
|
||||
* the same rule `inMemberDotDir` keeps for a member's home everywhere.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const zlib = require("node:zlib");
|
||||
const { Readable, Transform } = require("node:stream");
|
||||
const { AsyncLocalStorage } = require("node:async_hooks");
|
||||
const { pipeline } = require("node:stream/promises");
|
||||
|
||||
const { isSecretOnDisk, lanePath, laneRoot, resolveInLane } = require("./git-diff");
|
||||
const runAs = require("./run-as");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
/** One uploaded file, streamed (the client keeps a whole batch to this too). */
|
||||
const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||
/** One zip download, uncompressed — and below ZIP64's 4 GiB offsets. */
|
||||
const ZIP_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
const ZIP_MAX_FILES = 50_000;
|
||||
const LIST_MAX = 5000;
|
||||
const TRASH_DAYS = 7;
|
||||
const CONFLICTS = new Set(["fail", "skip", "keep", "replace"]);
|
||||
|
||||
const bad = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
|
||||
|
||||
// Set while a request works on a user's home (see the file header).
|
||||
const homeCtx = new AsyncLocalStorage();
|
||||
/** Run `fn` with `dir` treated as a home: its top-level dot entries are off limits. */
|
||||
const inHome = (fn) => homeCtx.run(true, fn);
|
||||
/** Whether `abs` is, or lies under, a dot entry at the top of a home root `root`. */
|
||||
function topDot(root, abs) {
|
||||
if (homeCtx.getStore() !== true) return false;
|
||||
const rel = path.relative(root, abs);
|
||||
return Boolean(rel) && !escapes(root, abs) && rel.split(path.sep)[0].startsWith(".");
|
||||
}
|
||||
const hidden = () => bad("ESECRET", "hidden files at the top of a home are not reachable here");
|
||||
const gitInside = (rel) => rel.split("/").includes(".git");
|
||||
const escapes = (base, target) => {
|
||||
const r = path.relative(base, target);
|
||||
return r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r);
|
||||
};
|
||||
|
||||
/** One plain path segment a user may name. */
|
||||
function validName(n) {
|
||||
const name = typeof n === "string" ? n.trim() : "";
|
||||
if (
|
||||
!name ||
|
||||
name === "." ||
|
||||
name === ".." ||
|
||||
/[/\\\0]/.test(name) ||
|
||||
Buffer.byteLength(name) > 255
|
||||
) {
|
||||
throw bad("EBADNAME", "a name without '/' or '\\', at most 255 bytes");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function conflictOf(v) {
|
||||
return CONFLICTS.has(v) ? v : "fail";
|
||||
}
|
||||
|
||||
/** A workspace folder by relative path ("" = the workspace root). */
|
||||
async function folder(dir, rel) {
|
||||
const root = await laneRoot(dir);
|
||||
if (!rel) return { rel: "", abs: root, root };
|
||||
const p = lanePath(rel);
|
||||
const abs = await resolveInLane(dir, p);
|
||||
if (topDot(root, abs)) throw hidden();
|
||||
const st = await fs.promises.stat(abs).catch(() => null);
|
||||
if (!st?.isDirectory()) throw bad("ENOFILE", "no such folder in the workspace");
|
||||
return { rel: p, abs, root };
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry by relative path, existing or not: its parent resolved and
|
||||
* confined, its name kept (a symlink is the link). `st` is its lstat or null.
|
||||
*/
|
||||
async function locate(dir, rel) {
|
||||
const p = lanePath(rel);
|
||||
if (await isSecretOnDisk(dir, p)) throw bad("ESECRET", "that path holds secrets");
|
||||
const parent = path.posix.dirname(p);
|
||||
const name = path.posix.basename(p);
|
||||
const par = await folder(dir, parent === "." ? "" : parent);
|
||||
const abs = path.join(par.abs, name);
|
||||
if (escapes(par.root, abs)) throw bad("EBADPATH", "path escapes the workspace directory");
|
||||
if (topDot(par.root, abs)) throw hidden();
|
||||
if (await isSecretOnDisk(par.root, path.relative(par.root, abs))) {
|
||||
throw bad("ESECRET", "that path holds secrets");
|
||||
}
|
||||
const st = await fs.promises.lstat(abs).catch(() => null);
|
||||
return { rel: p, name, abs, parent: par, root: par.root, st };
|
||||
}
|
||||
|
||||
async function existing(dir, rel) {
|
||||
const loc = await locate(dir, rel);
|
||||
if (!loc.st) throw bad("ENOFILE", `no such file or folder: ${loc.rel}`);
|
||||
return loc;
|
||||
}
|
||||
|
||||
function writable(loc) {
|
||||
if (gitInside(loc.rel)) throw bad("EBADPATH", "nothing in .git is changed from here");
|
||||
}
|
||||
|
||||
/** The account a write at `abs` runs as (null = the host); EMEMBERGONE throws. */
|
||||
const accountAt = (abs) => runAs.accountForPath(abs);
|
||||
|
||||
function needMove(acct) {
|
||||
if (acct && !runAs.writeAsCanMove()) {
|
||||
throw bad(
|
||||
"EUPGRADE",
|
||||
"moving or copying in a member's folder needs the updated helper: run `sudo agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A name in `parentAbs` not taken yet: "a (1).txt", "a (2).txt"… */
|
||||
async function freeName(parentAbs, name) {
|
||||
const ext = name.startsWith(".") ? "" : path.extname(name);
|
||||
const stem = ext ? name.slice(0, -ext.length) : name;
|
||||
for (let i = 1; i < 10_000; i++) {
|
||||
const n = `${stem} (${i})${ext}`;
|
||||
if (!(await fs.promises.lstat(path.join(parentAbs, n)).catch(() => null))) return n;
|
||||
}
|
||||
throw bad("EEXISTS", `no free name for ${name}`);
|
||||
}
|
||||
|
||||
// ── Reading ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A folder's entries, folders first: `{name, type, size, mtime}`. */
|
||||
async function list(dir, rel = "") {
|
||||
const f = await folder(dir, rel);
|
||||
const names = (await fs.promises.readdir(f.abs)).sort((a, b) => a.localeCompare(b));
|
||||
const entries = [];
|
||||
for (const name of names.slice(0, LIST_MAX)) {
|
||||
const relChild = f.rel ? `${f.rel}/${name}` : name;
|
||||
if (topDot(f.root, path.join(f.abs, name))) continue;
|
||||
if (await isSecretOnDisk(f.root, path.relative(f.root, path.join(f.abs, name)))) continue;
|
||||
const st = await fs.promises.lstat(path.join(f.abs, name)).catch(() => null);
|
||||
if (!st) continue;
|
||||
let type = st.isDirectory()
|
||||
? "dir"
|
||||
: st.isFile()
|
||||
? "file"
|
||||
: st.isSymbolicLink()
|
||||
? "link"
|
||||
: "other";
|
||||
if (type === "link") {
|
||||
// Shown as what it points to, but only while that stays inside.
|
||||
const real = await fs.promises.realpath(path.join(f.abs, name)).catch(() => null);
|
||||
const tst =
|
||||
real && !escapes(f.root, real) ? await fs.promises.stat(real).catch(() => null) : null;
|
||||
if (tst?.isDirectory()) type = "linkdir";
|
||||
}
|
||||
entries.push({
|
||||
name,
|
||||
path: relChild,
|
||||
type,
|
||||
size: st.isFile() ? st.size : null,
|
||||
mtime: st.mtimeMs,
|
||||
});
|
||||
}
|
||||
const rank = (e) => (e.type === "dir" || e.type === "linkdir" ? 0 : 1);
|
||||
entries.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
||||
return {
|
||||
path: f.rel,
|
||||
parent: f.rel ? (path.posix.dirname(f.rel) === "." ? "" : path.posix.dirname(f.rel)) : null,
|
||||
entries,
|
||||
truncated: names.length > LIST_MAX,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Making ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function mkdir(dir, rel) {
|
||||
const loc = await locate(dir, rel);
|
||||
validName(loc.name);
|
||||
writable(loc);
|
||||
if (loc.st) throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
|
||||
const acct = accountAt(loc.abs);
|
||||
if (acct) runAs.mkdirAs(acct, loc.abs);
|
||||
else await fs.promises.mkdir(loc.abs);
|
||||
return { path: loc.rel };
|
||||
}
|
||||
|
||||
async function newFile(dir, rel) {
|
||||
const loc = await locate(dir, rel);
|
||||
validName(loc.name);
|
||||
writable(loc);
|
||||
if (loc.st) throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
|
||||
const acct = accountAt(loc.abs);
|
||||
if (acct) await runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: "" });
|
||||
else await fs.promises.writeFile(loc.abs, "", { flag: "wx" });
|
||||
return { path: loc.rel };
|
||||
}
|
||||
|
||||
/** Make every missing folder of `relDir` (each one checked like `mkdir`). */
|
||||
async function ensureFolders(dir, relDir) {
|
||||
if (!relDir) return;
|
||||
const parts = lanePath(relDir).split("/");
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
const sub = parts.slice(0, i).join("/");
|
||||
const loc = await locate(dir, sub);
|
||||
if (loc.st) {
|
||||
if (!loc.st.isDirectory()) throw bad("EEXISTS", `${sub} is a file, not a folder`);
|
||||
continue;
|
||||
}
|
||||
validName(loc.name);
|
||||
writable(loc);
|
||||
const acct = accountAt(loc.abs);
|
||||
if (acct) runAs.mkdirAs(acct, loc.abs);
|
||||
else await fs.promises.mkdir(loc.abs);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Moving, copying ────────────────────────────────────────────────────────
|
||||
|
||||
/** Host-side move: a rename, or copy + remove across filesystems. */
|
||||
async function hostMove(from, to) {
|
||||
try {
|
||||
await fs.promises.rename(from, to);
|
||||
} catch (e) {
|
||||
if (e.code !== "EXDEV") throw e;
|
||||
await fs.promises.cp(from, to, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
await fs.promises.rm(from, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function moveAs(acct, from, to) {
|
||||
if (acct) {
|
||||
needMove(acct);
|
||||
await runAs.writeAsAsync(acct, from, ["move", to]);
|
||||
} else {
|
||||
await hostMove(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The targets of a move/copy of `srcs` into `dest`, conflicts settled by
|
||||
* `conflict` — or EEXISTS (409) naming every taken name when it is `fail`,
|
||||
* before anything is done.
|
||||
*/
|
||||
async function plan(srcs, dest, conflict) {
|
||||
const out = [];
|
||||
const taken = [];
|
||||
for (const s of srcs) {
|
||||
let name = s.name;
|
||||
let target = path.join(dest.abs, name);
|
||||
const st = await fs.promises.lstat(target).catch(() => null);
|
||||
// Onto itself is handled below (a copy keeps both); only INTO itself is refused.
|
||||
if (s.st.isDirectory() && target.startsWith(`${s.abs}${path.sep}`)) {
|
||||
throw bad("EBADPATH", `${s.rel} cannot go inside itself`);
|
||||
}
|
||||
if (target === s.abs) {
|
||||
// Onto itself: a copy keeps both, a move does nothing.
|
||||
out.push({ src: s, target, name, self: true });
|
||||
continue;
|
||||
}
|
||||
if (st) {
|
||||
if (conflict === "fail") {
|
||||
taken.push(name);
|
||||
continue;
|
||||
}
|
||||
if (conflict === "skip") continue;
|
||||
if (conflict === "keep") {
|
||||
name = await freeName(dest.abs, name);
|
||||
target = path.join(dest.abs, name);
|
||||
} else {
|
||||
out.push({ src: s, target, name, replace: true });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push({ src: s, target, name });
|
||||
}
|
||||
if (taken.length) {
|
||||
throw bad("EEXISTS", `already in the destination: ${taken.join(", ")}`, { names: taken });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sameAccount(...paths) {
|
||||
if (!runAs.sameOwner(...paths)) {
|
||||
throw bad("EFOREIGN", "files move or copy only between folders of one account");
|
||||
}
|
||||
}
|
||||
|
||||
async function move(dir, rels, destRel, { conflict, trash } = {}) {
|
||||
const dest = await folder(dir, destRel);
|
||||
if (gitInside(dest.rel)) throw bad("EBADPATH", "nothing is moved into .git");
|
||||
const srcs = [];
|
||||
for (const r of rels) {
|
||||
const s = await existing(dir, r);
|
||||
writable(s);
|
||||
srcs.push(s);
|
||||
}
|
||||
sameAccount(dest.abs, ...srcs.map((s) => s.abs));
|
||||
const steps = await plan(srcs, dest, conflictOf(conflict));
|
||||
const done = [];
|
||||
for (const st of steps) {
|
||||
if (st.self) continue;
|
||||
const acct = accountAt(st.target);
|
||||
if (st.replace) await trash(path.relative(dest.root, st.target).split(path.sep).join("/"));
|
||||
await moveAs(acct, st.src.abs, st.target);
|
||||
done.push(path.relative(dest.root, st.target).split(path.sep).join("/"));
|
||||
}
|
||||
return { moved: done };
|
||||
}
|
||||
|
||||
async function rename(dir, rel, newName) {
|
||||
const s = await existing(dir, rel);
|
||||
writable(s);
|
||||
const name = validName(newName);
|
||||
if (name === s.name) return { path: s.rel };
|
||||
const to = await locate(dir, s.rel.includes("/") ? `${path.posix.dirname(s.rel)}/${name}` : name);
|
||||
writable(to);
|
||||
if (to.st) throw bad("EEXISTS", `${to.rel} already exists`, { names: [name] });
|
||||
await moveAs(accountAt(to.abs), s.abs, to.abs);
|
||||
return { path: to.rel };
|
||||
}
|
||||
|
||||
/** Copy `rels` of workspace `srcDir` into `destRel` of workspace `dir`. */
|
||||
async function copy(dir, rels, destRel, { srcDir = dir, srcHome, conflict, trash } = {}) {
|
||||
const dest = await folder(dir, destRel);
|
||||
if (gitInside(dest.rel)) throw bad("EBADPATH", "nothing is copied into .git");
|
||||
// The sources are checked under THEIR place's rule (a home's dot entries
|
||||
// stay out of reach; a workspace's `.github` does not), the destination
|
||||
// under this call's.
|
||||
const destHome = homeCtx.getStore() === true;
|
||||
const fromHome = srcHome ?? (srcDir === dir ? destHome : false);
|
||||
const srcs = await homeCtx.run(fromHome, async () => {
|
||||
const out = [];
|
||||
for (const r of rels) {
|
||||
const s = await existing(srcDir, r);
|
||||
const real = await fs.promises.realpath(s.abs).catch(() => s.abs);
|
||||
if (topDot(s.root, real)) throw hidden();
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
sameAccount(dest.abs, ...srcs.map((s) => s.abs));
|
||||
let steps = await plan(srcs, dest, conflictOf(conflict));
|
||||
// A copy onto itself keeps both, like a file manager's paste in place.
|
||||
steps = await Promise.all(
|
||||
steps.map(async (st) =>
|
||||
st.self ? { ...st, name: await freeName(dest.abs, st.name), self: false } : st
|
||||
)
|
||||
);
|
||||
const done = [];
|
||||
for (const st of steps) {
|
||||
const target = path.join(dest.abs, st.name);
|
||||
const acct = accountAt(target);
|
||||
if (st.replace) await trash(path.relative(dest.root, target).split(path.sep).join("/"));
|
||||
if (acct) {
|
||||
needMove(acct);
|
||||
await runAs.writeAsAsync(acct, st.src.abs, ["copy", target]);
|
||||
} else {
|
||||
await fs.promises.cp(st.src.abs, target, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
}
|
||||
done.push(path.relative(dest.root, target).split(path.sep).join("/"));
|
||||
}
|
||||
return { copied: done };
|
||||
}
|
||||
|
||||
// ── Trash ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const TRASH_ID_RE = /^[a-z0-9]{8,40}$/;
|
||||
|
||||
/** Where `acct`'s (null: the host's, for AgentHub user `userKey`) trash lives. */
|
||||
function trashRoot(acct, userKey) {
|
||||
if (acct) return path.join(acct.home, ".local", "share", "agenthub", "trash");
|
||||
return path.join(getDataDir(), "trash", String(userKey || "local").replace(/[^\w.-]/g, "_"));
|
||||
}
|
||||
|
||||
/** Move `rel` of workspace `dir` (workspace `laneId`) into its account's trash. */
|
||||
async function trashOne(dir, laneId, rel, userKey) {
|
||||
const loc = await existing(dir, rel);
|
||||
writable(loc);
|
||||
const acct = accountAt(loc.abs);
|
||||
const id = `${Date.now().toString(36)}${crypto.randomBytes(6).toString("hex")}`;
|
||||
const idDir = path.join(trashRoot(acct, userKey), id);
|
||||
const meta = JSON.stringify({
|
||||
id,
|
||||
name: loc.name,
|
||||
laneId,
|
||||
rel: loc.rel,
|
||||
isDir: loc.st.isDirectory(),
|
||||
deletedAt: new Date().toISOString(),
|
||||
});
|
||||
if (acct) {
|
||||
needMove(acct);
|
||||
runAs.mkdirAs(acct, idDir);
|
||||
await runAs.writeAsAsync(acct, path.join(idDir, "meta.json"), ["private"], { input: meta });
|
||||
await runAs.writeAsAsync(acct, loc.abs, ["move", path.join(idDir, "item")]);
|
||||
} else {
|
||||
await fs.promises.mkdir(idDir, { recursive: true, mode: 0o700 });
|
||||
await fs.promises.writeFile(path.join(idDir, "meta.json"), meta, { mode: 0o600 });
|
||||
await hostMove(loc.abs, path.join(idDir, "item"));
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function remove(dir, laneId, rels, userKey) {
|
||||
const ids = [];
|
||||
for (const r of rels) ids.push(await trashOne(dir, laneId, r, userKey));
|
||||
return { trashed: ids };
|
||||
}
|
||||
|
||||
function readMeta(acct, idDir) {
|
||||
try {
|
||||
const text = acct
|
||||
? runAs.readFileSafe(path.join(idDir, "meta.json"), "utf8", { max: 64 * 1024 })
|
||||
: fs.readFileSync(path.join(idDir, "meta.json"), "utf8");
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function dropTrash(acct, idDir) {
|
||||
if (acct) runAs.removeAs(acct, idDir);
|
||||
else await fs.promises.rm(idDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** The trash of the account owning `dir`, expired entries dropped first. */
|
||||
async function listTrash(dir, userKey) {
|
||||
const root = await laneRoot(dir);
|
||||
const acct = accountAt(root);
|
||||
const tr = trashRoot(acct, userKey);
|
||||
let ids = [];
|
||||
try {
|
||||
ids = (await fs.promises.readdir(tr)).filter((n) => TRASH_ID_RE.test(n));
|
||||
} catch {
|
||||
return { items: [] };
|
||||
}
|
||||
const cutoff = Date.now() - TRASH_DAYS * 86_400_000;
|
||||
const items = [];
|
||||
for (const id of ids) {
|
||||
const meta = readMeta(acct, path.join(tr, id));
|
||||
if (!meta || Date.parse(meta.deletedAt) < cutoff) {
|
||||
await dropTrash(acct, path.join(tr, id)).catch(() => {});
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
id,
|
||||
name: meta.name,
|
||||
laneId: meta.laneId,
|
||||
path: meta.rel,
|
||||
isDir: !!meta.isDir,
|
||||
deletedAt: meta.deletedAt,
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
return { items, keepDays: TRASH_DAYS };
|
||||
}
|
||||
|
||||
function trashEntry(acct, userKey, id) {
|
||||
if (!TRASH_ID_RE.test(String(id || ""))) throw bad("EBADPATH", "no such trash item");
|
||||
const idDir = path.join(trashRoot(acct, userKey), id);
|
||||
const meta = readMeta(acct, idDir);
|
||||
if (!meta) throw bad("ENOFILE", "no such trash item");
|
||||
return { idDir, meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* Put trash item `id` back where it was in workspace `dir` (its folders made
|
||||
* again if gone; a taken name gets a free one). The item must belong to the
|
||||
* account of `dir` — `resolveLane(meta.laneId)` returns that workspace's cwd
|
||||
* when the caller may write there, else null (restored into `dir` instead).
|
||||
*/
|
||||
async function restore(dir, id, userKey, resolveLane) {
|
||||
const root = await laneRoot(dir);
|
||||
const acct = accountAt(root);
|
||||
const { idDir, meta } = trashEntry(acct, userKey, id);
|
||||
const home = (meta.laneId != null && resolveLane(meta.laneId)) || dir;
|
||||
const parentRel = path.posix.dirname(String(meta.rel || meta.name));
|
||||
const relDir = parentRel === "." ? "" : parentRel;
|
||||
await ensureFolders(home, relDir);
|
||||
const dest = await folder(home, relDir);
|
||||
sameAccount(dest.abs, idDir);
|
||||
let name = validName(meta.name);
|
||||
if (await fs.promises.lstat(path.join(dest.abs, name)).catch(() => null)) {
|
||||
name = await freeName(dest.abs, name);
|
||||
}
|
||||
const target = path.join(dest.abs, name);
|
||||
await moveAs(acct, path.join(idDir, "item"), target);
|
||||
await dropTrash(acct, idDir);
|
||||
return { path: relDir ? `${relDir}/${name}` : name, laneId: home === dir ? null : meta.laneId };
|
||||
}
|
||||
|
||||
async function purgeTrash(dir, userKey, id) {
|
||||
const root = await laneRoot(dir);
|
||||
const acct = accountAt(root);
|
||||
if (id === "all") {
|
||||
const { items } = await listTrash(dir, userKey);
|
||||
for (const it of items) await dropTrash(acct, path.join(trashRoot(acct, userKey), it.id));
|
||||
return { purged: items.length };
|
||||
}
|
||||
const { idDir } = trashEntry(acct, userKey, id);
|
||||
await dropTrash(acct, idDir);
|
||||
return { purged: 1 };
|
||||
}
|
||||
|
||||
// ── Upload ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A pass-through that fails once more than `max` bytes went by. */
|
||||
function capped(max) {
|
||||
let n = 0;
|
||||
return new Transform({
|
||||
transform(chunk, _enc, cb) {
|
||||
n += chunk.length;
|
||||
if (n > max) return cb(bad("ETOOBIG", `a file may be at most ${max / 1024 / 1024} MB`));
|
||||
cb(null, chunk);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the request `stream` to `rel` (sub-folders made as needed). Returns
|
||||
* `{path}`, or `{skipped: true}` under `conflict: "skip"`.
|
||||
*/
|
||||
async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
|
||||
const p = lanePath(rel);
|
||||
for (const seg of p.split("/")) validName(seg);
|
||||
if (gitInside(p)) throw bad("EBADPATH", "nothing is uploaded into .git");
|
||||
if (length != null && Number(length) > UPLOAD_MAX_BYTES) {
|
||||
throw bad("ETOOBIG", `a file may be at most ${UPLOAD_MAX_BYTES / 1024 / 1024} MB`);
|
||||
}
|
||||
const parentRel = path.posix.dirname(p) === "." ? "" : path.posix.dirname(p);
|
||||
await ensureFolders(dir, parentRel);
|
||||
let loc = await locate(dir, p);
|
||||
const policy = conflictOf(conflict);
|
||||
if (loc.st) {
|
||||
if (loc.st.isDirectory()) throw bad("EEXISTS", `${loc.rel} is a folder`, { names: [loc.name] });
|
||||
if (policy === "fail") throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
|
||||
if (policy === "skip") {
|
||||
stream.resume();
|
||||
return { path: loc.rel, skipped: true };
|
||||
}
|
||||
if (policy === "keep") {
|
||||
const name = await freeName(loc.parent.abs, loc.name);
|
||||
loc = await locate(dir, parentRel ? `${parentRel}/${name}` : name);
|
||||
} else {
|
||||
await trash(loc.rel);
|
||||
}
|
||||
}
|
||||
const acct = accountAt(loc.abs);
|
||||
const limit = capped(UPLOAD_MAX_BYTES);
|
||||
if (acct) {
|
||||
// write-as `replace`: a new file renamed over the name, as the member.
|
||||
const done = runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: limit });
|
||||
await Promise.all([
|
||||
pipeline(stream, limit).catch((e) => {
|
||||
limit.destroy(e);
|
||||
throw e;
|
||||
}),
|
||||
done,
|
||||
]);
|
||||
} else {
|
||||
const tmp = path.join(
|
||||
loc.parent.abs,
|
||||
`.${loc.name}.agenthub-upload-${crypto.randomBytes(4).toString("hex")}`
|
||||
);
|
||||
try {
|
||||
await pipeline(stream, limit, fs.createWriteStream(tmp, { flags: "wx" }));
|
||||
// Linked, not renamed: a name taken meanwhile is refused, never replaced.
|
||||
await fs.promises.link(tmp, loc.abs).catch(async (e) => {
|
||||
if (e.code === "EEXIST")
|
||||
throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
|
||||
if (e.code === "EPERM" || e.code === "ENOTSUP") return fs.promises.rename(tmp, loc.abs);
|
||||
throw e;
|
||||
});
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { force: true });
|
||||
}
|
||||
}
|
||||
return { path: loc.rel };
|
||||
}
|
||||
|
||||
/**
|
||||
* One file for a download (the Files tab's `openDownload`, after this
|
||||
* module's own checks — the home rule included, on the file's REAL path).
|
||||
*/
|
||||
async function download(dir, rel) {
|
||||
const loc = await existing(dir, rel);
|
||||
const real = await fs.promises.realpath(loc.abs).catch(() => loc.abs);
|
||||
if (topDot(loc.root, real) || escapes(loc.root, real)) throw hidden();
|
||||
return require("./lane-file-transfer").openDownload(dir, loc.rel);
|
||||
}
|
||||
|
||||
// ── Zip download ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Every regular file and folder under `rels` (no symlink followed, no secret). */
|
||||
async function collect(dir, rels) {
|
||||
const root = await laneRoot(dir);
|
||||
const files = [];
|
||||
const dirs = [];
|
||||
let total = 0;
|
||||
const visit = async (abs, inZip) => {
|
||||
const rel = path.relative(root, abs);
|
||||
if (topDot(root, abs) || (await isSecretOnDisk(root, rel))) return;
|
||||
const st = await fs.promises.lstat(abs).catch(() => null);
|
||||
if (!st) return;
|
||||
if (st.isDirectory()) {
|
||||
dirs.push({ name: `${inZip}/`, mtime: st.mtime });
|
||||
for (const n of (await fs.promises.readdir(abs)).sort())
|
||||
await visit(path.join(abs, n), `${inZip}/${n}`);
|
||||
} else if (st.isFile()) {
|
||||
total += st.size;
|
||||
if (total > ZIP_MAX_BYTES)
|
||||
throw bad("ETOOBIG", "more than 2 GB to zip — download fewer files");
|
||||
if (files.length >= ZIP_MAX_FILES) throw bad("ETOOBIG", "more than 50,000 files to zip");
|
||||
files.push({ abs, name: inZip, size: st.size, mtime: st.mtime, mode: st.mode });
|
||||
}
|
||||
};
|
||||
for (const r of rels) {
|
||||
const loc = await existing(dir, r);
|
||||
await visit(loc.abs, loc.name);
|
||||
}
|
||||
return { files, dirs, total };
|
||||
}
|
||||
|
||||
function dosTime(d) {
|
||||
const t = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
|
||||
const day =
|
||||
((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
|
||||
return { t, day };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a zip of `rels` (files and folders) into `out`. Deflate with data
|
||||
* descriptors, UTF-8 names, no ZIP64 (collect caps it at 2 GB). A member's
|
||||
* file is opened through `openSafeAsync` (a regular file they own).
|
||||
*/
|
||||
async function zip(dir, rels, out) {
|
||||
const { files, dirs } = await collect(dir, rels);
|
||||
let offset = 0;
|
||||
const central = [];
|
||||
const write = (buf) =>
|
||||
new Promise((resolve, reject) => {
|
||||
offset += buf.length;
|
||||
out.write(buf, (e) => (e ? reject(e) : resolve()));
|
||||
});
|
||||
const header = (name, method, flags, d) => {
|
||||
const n = Buffer.from(name, "utf8");
|
||||
const h = Buffer.alloc(30);
|
||||
h.writeUInt32LE(0x04034b50, 0);
|
||||
h.writeUInt16LE(20, 4);
|
||||
h.writeUInt16LE(flags, 6);
|
||||
h.writeUInt16LE(method, 8);
|
||||
h.writeUInt16LE(d.t, 10);
|
||||
h.writeUInt16LE(d.day, 12);
|
||||
h.writeUInt16LE(n.length, 26);
|
||||
return Buffer.concat([h, n]);
|
||||
};
|
||||
for (const d of dirs) {
|
||||
const dt = dosTime(d.mtime);
|
||||
central.push({
|
||||
name: d.name,
|
||||
method: 0,
|
||||
flags: 0x0800,
|
||||
dt,
|
||||
crc: 0,
|
||||
csize: 0,
|
||||
size: 0,
|
||||
offset,
|
||||
attr: (0o40755 << 16) | 0x10,
|
||||
});
|
||||
await write(header(d.name, 0, 0x0800, dt));
|
||||
}
|
||||
for (const f of files) {
|
||||
const dt = dosTime(f.mtime);
|
||||
const start = offset;
|
||||
await write(header(f.name, 8, 0x0808, dt));
|
||||
let crc = 0;
|
||||
let size = 0;
|
||||
let csize = 0;
|
||||
let fh;
|
||||
try {
|
||||
fh = await runAs.openSafeAsync(f.abs);
|
||||
} catch {
|
||||
fh = null; // gone, or not the member's own regular file: stored empty
|
||||
}
|
||||
const tap = new Transform({
|
||||
transform(c, _e, cb) {
|
||||
crc = zlib.crc32(c, crc);
|
||||
size += c.length;
|
||||
cb(null, c);
|
||||
},
|
||||
});
|
||||
const count = new Transform({
|
||||
transform(c, _e, cb) {
|
||||
csize += c.length;
|
||||
offset += c.length;
|
||||
cb(null, c);
|
||||
},
|
||||
});
|
||||
const src =
|
||||
fh && f.size ? fh.createReadStream({ start: 0, end: f.size - 1 }) : Readable.from([]);
|
||||
if (fh && !f.size) await fh.close();
|
||||
await pipeline(src, tap, zlib.createDeflateRaw(), count, out, { end: false });
|
||||
const dd = Buffer.alloc(16);
|
||||
dd.writeUInt32LE(0x08074b50, 0);
|
||||
dd.writeUInt32LE(crc >>> 0, 4);
|
||||
dd.writeUInt32LE(csize, 8);
|
||||
dd.writeUInt32LE(size, 12);
|
||||
await write(dd);
|
||||
central.push({
|
||||
name: f.name,
|
||||
method: 8,
|
||||
flags: 0x0808,
|
||||
dt,
|
||||
crc,
|
||||
csize,
|
||||
size,
|
||||
offset: start,
|
||||
attr: ((f.mode & 0o777) | 0o100000) << 16,
|
||||
});
|
||||
}
|
||||
const cdStart = offset;
|
||||
for (const c of central) {
|
||||
const n = Buffer.from(c.name, "utf8");
|
||||
const h = Buffer.alloc(46);
|
||||
h.writeUInt32LE(0x02014b50, 0);
|
||||
h.writeUInt16LE((3 << 8) | 20, 4);
|
||||
h.writeUInt16LE(20, 6);
|
||||
h.writeUInt16LE(c.flags, 8);
|
||||
h.writeUInt16LE(c.method, 10);
|
||||
h.writeUInt16LE(c.dt.t, 12);
|
||||
h.writeUInt16LE(c.dt.day, 14);
|
||||
h.writeUInt32LE(c.crc >>> 0, 16);
|
||||
h.writeUInt32LE(c.csize, 20);
|
||||
h.writeUInt32LE(c.size, 24);
|
||||
h.writeUInt16LE(n.length, 28);
|
||||
h.writeUInt32LE(c.attr >>> 0, 38);
|
||||
h.writeUInt32LE(c.offset, 42);
|
||||
await write(Buffer.concat([h, n]));
|
||||
}
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(central.length, 8);
|
||||
end.writeUInt16LE(central.length, 10);
|
||||
end.writeUInt32LE(offset - cdStart, 12);
|
||||
end.writeUInt32LE(cdStart, 16);
|
||||
await write(end);
|
||||
out.end();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
inHome,
|
||||
download,
|
||||
list,
|
||||
mkdir,
|
||||
newFile,
|
||||
rename,
|
||||
move,
|
||||
copy,
|
||||
remove,
|
||||
trashOne,
|
||||
listTrash,
|
||||
restore,
|
||||
purgeTrash,
|
||||
upload,
|
||||
zip,
|
||||
collect,
|
||||
UPLOAD_MAX_BYTES,
|
||||
ZIP_MAX_BYTES,
|
||||
TRASH_DAYS,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file Which HOME folder a user's File Explorer opens (`/api/homes/*`):
|
||||
* - S3: their own Linux account's home (`/srv/agenthub/users/<name>`);
|
||||
* - server mode without S3: their own folder under AGENTHUB_USERS_ROOT;
|
||||
* - local mode, or an admin with no Linux account of their own: the host
|
||||
* account's home.
|
||||
* `me` is always the caller's. An admin may also open another user's home —
|
||||
* read-only (the routes refuse every write, `EREADONLY`) and written to the
|
||||
* audit log — the "view other users' homes" option. A home is never a
|
||||
* workspace (the S3 rule stands); it is browsed with `file-explorer.js`'s
|
||||
* `inHome`, which keeps every dot entry at its top out of reach.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const auth = require("./auth");
|
||||
const linux = require("./linux-accounts");
|
||||
const runAs = require("./run-as");
|
||||
const { ownFolder } = require("./scope");
|
||||
|
||||
const isDir = (p) => {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** The home of AgentHub user `user` (a users row or req.user), or null. */
|
||||
function homeOf(user) {
|
||||
if (!auth.isServerMode()) return os.homedir();
|
||||
if (!user) return null;
|
||||
if (linux.s3Enabled()) {
|
||||
let acct = null;
|
||||
try {
|
||||
acct = runAs.accountForUser(user.username);
|
||||
} catch {
|
||||
acct = null; // disabled or not provisioned: no home to open
|
||||
}
|
||||
if (acct) return acct.home;
|
||||
return user.role === "admin" ? os.homedir() : null;
|
||||
}
|
||||
const own = ownFolder({ user });
|
||||
if (own) return isDir(own) ? own : null;
|
||||
return user.role === "admin" ? os.homedir() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The homes `req` may open: `{ id: "me" | <username>, label, readOnly }`.
|
||||
* Others' only for an admin, and only those that resolve to a folder.
|
||||
*/
|
||||
function listHomes(req) {
|
||||
const out = [];
|
||||
const me = req.user || null;
|
||||
if (homeOf(me))
|
||||
out.push({ id: "me", label: me?.displayName || me?.username || "", readOnly: false });
|
||||
if (me?.role === "admin" && auth.isServerMode()) {
|
||||
for (const u of auth.listUsers()) {
|
||||
if (u.id === me.id || u.disabled) continue;
|
||||
const home = homeOf(u);
|
||||
if (home && home !== homeOf(me)) {
|
||||
out.push({ id: u.username, label: u.displayName || u.username, readOnly: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ dir, readOnly, ownerId }` for home `who` of `req`, or a thrown ENOHOME
|
||||
* (404) — never another user's for a non-admin.
|
||||
*/
|
||||
function resolveHome(req, who) {
|
||||
const none = () => Object.assign(new Error("no such home"), { code: "ENOHOME" });
|
||||
const me = req.user || null;
|
||||
if (who === "me") {
|
||||
const dir = homeOf(me);
|
||||
if (!dir) throw none();
|
||||
return { dir: path.resolve(dir), readOnly: false, ownerId: me?.id ?? null };
|
||||
}
|
||||
if (me?.role !== "admin" || !auth.isServerMode()) throw none();
|
||||
const u = auth.listUsers().find((x) => x.username === String(who) && !x.disabled);
|
||||
const dir = u && homeOf(u);
|
||||
if (!dir) throw none();
|
||||
return { dir: path.resolve(dir), readOnly: true, ownerId: u.id };
|
||||
}
|
||||
|
||||
module.exports = { homeOf, listHomes, resolveHome };
|
||||
@@ -29,12 +29,20 @@ const confPath = () => process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server
|
||||
|
||||
// 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;
|
||||
confCache = { at: Date.now(), path: file, value: readConf(file) };
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -135,6 +143,7 @@ function provision(action, username) {
|
||||
|
||||
function __resetConfCache() {
|
||||
confCache = { at: 0, path: null, value: null };
|
||||
lastGood = { path: null, value: null };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -34,7 +34,17 @@ const https = require("node:https");
|
||||
const IDLE_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Request headers never passed upstream: hop-by-hop, the pane's token, cookies. */
|
||||
/**
|
||||
* Set on every request the gateway sends upstream. A personal profile may
|
||||
* name ANY http(s) base — a loopback one included — and the gateway sends
|
||||
* from the dashboard's own uid, which hook auth trusts. The dashboard refuses
|
||||
* every request carrying this mark (server/index.js), so a profile pointed
|
||||
* back at it can never reach a route as the host; a real proxy ignores it.
|
||||
*/
|
||||
const GATEWAY_MARK = "x-agenthub-via-gateway";
|
||||
|
||||
const DROP_REQUEST = new Set([
|
||||
GATEWAY_MARK,
|
||||
"host",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
@@ -151,6 +161,7 @@ function proxyGateway(req, res) {
|
||||
headers.authorization = `Bearer ${profile.apiKey}`;
|
||||
headers["x-api-key"] = profile.apiKey;
|
||||
}
|
||||
headers[GATEWAY_MARK] = "1";
|
||||
|
||||
const mod = target.protocol === "https:" ? https : http;
|
||||
const up = mod.request(target, { method: req.method, headers }, (answer) => {
|
||||
@@ -173,4 +184,10 @@ function proxyGateway(req, res) {
|
||||
req.pipe(up);
|
||||
}
|
||||
|
||||
module.exports = { proxyGateway, gatewayBase, setListenPort, listenPort: currentPort };
|
||||
module.exports = {
|
||||
proxyGateway,
|
||||
gatewayBase,
|
||||
setListenPort,
|
||||
listenPort: currentPort,
|
||||
GATEWAY_MARK,
|
||||
};
|
||||
|
||||
@@ -254,6 +254,53 @@ function writeAs(acct, file, mode, content = "") {
|
||||
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.
|
||||
@@ -358,6 +405,13 @@ const PANE_LAUNCH = "/opt/agenthub/libexec/pane-launch";
|
||||
*/
|
||||
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")];
|
||||
@@ -578,6 +632,9 @@ module.exports = {
|
||||
accountForUser,
|
||||
handoffWrite,
|
||||
writeAs,
|
||||
writeAsAsync,
|
||||
writeAsCanMove,
|
||||
__setSpawnImpl,
|
||||
mkdirAs,
|
||||
makeMemberFolder,
|
||||
removeAs,
|
||||
|
||||
+1
-1
@@ -223,7 +223,7 @@ function scopeGuard(req, res, next) {
|
||||
const m = /^\/(?:lanes|workspaces)\/(\d+)(?:\/|$)/i.exec(p);
|
||||
const ids = [...(m ? [m[1]] : []), ...lanes];
|
||||
if (ids.some((id) => !ownerStillFits(Number(id)))) {
|
||||
return res.status(403).json({
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: "EOWNERFOLDER",
|
||||
message: "this workspace runs as the host account: only an admin may change it",
|
||||
|
||||
+3
-1
@@ -150,7 +150,9 @@ function accountOf(name) {
|
||||
} catch (err) {
|
||||
// A member's workspace with no usable account never falls back to the
|
||||
// host's server: that would run the pane as the host in their folder.
|
||||
if (err.code === "EMEMBERGONE") throw err;
|
||||
// Under S3 neither does any other failure to tell (the DB unreadable
|
||||
// mid-call): the call fails instead of guessing "the host".
|
||||
if (err.code === "EMEMBERGONE" || require("./linux-accounts").s3Enabled()) throw err;
|
||||
return null; // no DB yet, a malformed name: the host's own server
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user