/** * @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) — and, for `/api/homes`, one recycle bin * per USER over every store their deletes went to — upload a file streamed from the request * into any (new) sub-folder (or many small ones as ONE batch request), 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 `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ĩ */ const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); const zlib = require("node:zlib"); const { PassThrough, Readable, Transform } = require("node:stream"); const { once } = require("node:events"); 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 `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 items = await readStore(acct, trashRoot(acct, userKey)); return { items, keepDays: TRASH_DAYS }; } /** The live items of one trash folder `tr` (of `acct`), expired ones dropped. */ async function readStore(acct, tr, store) { let ids = []; try { ids = (await fs.promises.readdir(tr)).filter((n) => TRASH_ID_RE.test(n)); } catch { return []; } 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, ...(store ? { store } : {}), }); } items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt)); return items; } function trashEntry(acct, userKey, id) { return entryAt(acct, trashRoot(acct, userKey), id); } function entryAt(acct, tr, id) { if (!TRASH_ID_RE.test(String(id || ""))) throw bad("EBADPATH", "no such trash item"); const idDir = path.join(tr, 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; return putBack(acct, idDir, meta, home, dir); } /** Move trash entry `idDir` back under folder `home` (`dir`: the fallback). */ async function putBack(acct, idDir, meta, home, 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 }; } // ── A user's recycle bin ─────────────────────────────────────────────────── // // One Trash per USER, whatever place a delete happened in: the trash of their // home's account (a member's `~/.local/share/agenthub/trash/`, or the host's // `/trash//` when their home is the host's) plus — when // that is another folder — the host trash under their user id, where what // they deleted in a host-folder workspace went. Items carry `store` // ("home" | "host") so a restore or purge finds the right one. /** The trash stores of the user `userKey` whose home is `homeDir`. */ function userStores(homeDir, userKey) { const acct = accountAt(homeDir); const out = [{ store: "home", acct, root: trashRoot(acct, userKey) }]; const host = trashRoot(null, userKey); if (acct || path.resolve(host) !== path.resolve(out[0].root)) { out.push({ store: "host", acct: null, root: host }); } return out; } function pickStore(homeDir, userKey, store) { const s = userStores(homeDir, userKey).find((x) => x.store === (store || "home")); if (!s) throw bad("EBADPATH", "no such trash"); return s; } /** Every item of the user's recycle bin, newest first. */ async function listUserTrash(homeDir, userKey) { const items = []; for (const s of userStores(homeDir, userKey)) { items.push(...(await readStore(s.acct, s.root, s.store))); } items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt)); return { items, keepDays: TRASH_DAYS }; } /** * Restore item `id` of the user's `store`: into the workspace it came from * when `resolveLane` still gives one, else — for the home store — into the * home. A host-store item whose workspace is gone has nowhere of the same * account to go (`ENOFILE`). */ async function restoreUserTrash(homeDir, userKey, store, id, resolveLane) { const s = pickStore(homeDir, userKey, store); const { idDir, meta } = entryAt(s.acct, s.root, id); const fallback = s.store === "home" ? homeDir : null; const home = (meta.laneId != null && resolveLane(meta.laneId)) || fallback; if (!home) { throw bad("ENOFILE", "the workspace this came from is gone — there is nowhere to restore it"); } return putBack(s.acct, idDir, meta, home, fallback); } /** Delete item `id` of `store` forever, or `"all"` of every store. */ async function purgeUserTrash(homeDir, userKey, store, id) { if (id === "all") { let n = 0; for (const s of userStores(homeDir, userKey)) { for (const it of await readStore(s.acct, s.root)) { await dropTrash(s.acct, path.join(s.root, it.id)); n++; } } return { purged: n }; } const s = pickStore(homeDir, userKey, store); const { idDir } = entryAt(s.acct, s.root, id); await dropTrash(s.acct, idDir); return { purged: 1 }; } 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 }; } // ── Batch upload ─────────────────────────────────────────────────────────── // // Many small files cost per FILE, not per byte: one request, one path walk // and — in a member's folder — one sudo write-as each. A batch carries them // in ONE request: a 4-byte big-endian length, that many bytes of JSON // `{files: [{path, size, mtime?}]}` (paths relative to `?dir=`), then each // file's bytes in that order. Every name is checked like `upload`'s and the // whole batch is planned (folders, conflicts) before the first write; a // member's batch is written by ONE `write-as extract` from a tar this // module writes itself (only folders and regular files, names it checked). const BATCH_MAX_FILES = 5000; /** A file in a batch is held in memory whole; bigger ones go through `upload`. */ const BATCH_FILE_MAX = 16 * 1024 * 1024; const BATCH_MAX_BYTES = 256 * 1024 * 1024; const BATCH_HEAD_MAX = 4 * 1024 * 1024; /** Whether a member's batch can be written (write-as `extract`, install-system re-run). */ function needExtract(acct) { if (acct && !runAs.writeAsCanExtract()) { throw bad( "EUPGRADE", "uploading many files at once into a member's folder needs the updated helper: run `agenthub server install-system` once" ); } } /** Reads exact byte counts from a stream (the request body). */ function byteReader(stream) { const it = stream[Symbol.asyncIterator](); let chunks = []; let have = 0; return { async read(n) { while (have < n) { const { value, done } = await it.next(); if (done) throw bad("EBADBODY", "the upload ended early"); const b = Buffer.isBuffer(value) ? value : Buffer.from(value); chunks.push(b); have += b.length; } const all = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, have); const rest = all.subarray(n); chunks = rest.length ? [rest] : []; have = rest.length; return all.subarray(0, n); }, /** Consume whatever is left, so an answer can still be sent. */ async drain() { chunks = []; have = 0; for (;;) if ((await it.next()).done) return; }, }; } /** The `files` list of a batch, each `{path, size, mtime}` checked for shape. */ function batchFiles(list) { if (!Array.isArray(list) || !list.length) throw bad("EBADBODY", "a batch names its files"); if (list.length > BATCH_MAX_FILES) { throw bad("ETOOBIG", `a batch holds at most ${BATCH_MAX_FILES} files`); } let total = 0; return list.map((f) => { const size = f?.size ?? 0; if (typeof f?.path !== "string" || !Number.isSafeInteger(size) || size < 0) { throw bad("EBADBODY", "each file is {path, size}"); } if (size > BATCH_FILE_MAX) { throw bad("ETOOBIG", `a file in a batch may be at most ${BATCH_FILE_MAX / 1024 / 1024} MB`); } total += size; if (total > BATCH_MAX_BYTES) { throw bad("ETOOBIG", `a batch may be at most ${BATCH_MAX_BYTES / 1024 / 1024} MB`); } const mtime = Number.isFinite(f.mtime) && f.mtime > 0 ? f.mtime : Date.now(); return { path: f.path, size, mtime }; }); } /** * Where each file of a batch goes, under `conflict`: the folders to make * (parents first) and per file its target, or `skip`/`replace`. `taken` * lists the names already there (paths relative to the batch's folder); * under `fail` they are not resolved — the caller refuses or reports them. */ async function planBatch(dir, dirRel, files, conflict) { const base = await folder(dir, dirRel || ""); if (gitInside(base.rel)) throw bad("EBADPATH", "nothing is uploaded into .git"); const full = (r) => (base.rel ? `${base.rel}/${r}` : r); // rel under base → {rel (workspace), abs, missing} const dirs = new Map([["", { rel: base.rel, abs: base.abs, missing: false }]]); const make = []; const dirOf = async (r) => { if (dirs.has(r)) return dirs.get(r); const upRel = path.posix.dirname(r) === "." ? "" : path.posix.dirname(r); const up = await dirOf(upRel); const name = path.posix.basename(r); const d = { rel: full(r), abs: path.join(up.abs, name), missing: true }; if (up.missing) { if (topDot(base.root, d.abs)) throw hidden(); if (await isSecretOnDisk(dir, d.rel)) throw bad("ESECRET", "that path holds secrets"); } else { const loc = await locate(dir, d.rel); if (loc.st && !loc.st.isDirectory()) { throw bad("EEXISTS", `${d.rel} is a file, not a folder`, { names: [r] }); } d.missing = !loc.st; } if (d.missing) make.push(d); dirs.set(r, d); return d; }; const seen = new Set(); // A free name for `keep` is free on disk AND not another file of this batch. const claimed = new Set(); const own = new Set( files.map((f) => { try { return lanePath(f.path); } catch { return ""; } }) ); const out = []; const taken = []; for (const f of files) { const r = lanePath(f.path); for (const seg of r.split("/")) validName(seg); if (gitInside(full(r))) throw bad("EBADPATH", "nothing is uploaded into .git"); if (seen.has(r)) throw bad("EBADPATH", `${r} is named twice`); seen.add(r); const upRel = path.posix.dirname(r) === "." ? "" : path.posix.dirname(r); const up = await dirOf(upRel); let name = path.posix.basename(r); let st = null; if (up.missing) { if (await isSecretOnDisk(dir, full(r))) throw bad("ESECRET", "that path holds secrets"); } else { const loc = await locate(dir, full(r)); st = loc.st; } const step = { ...f, rel: r, name, up, skip: false, replace: false }; if (st) { if (st.isDirectory()) throw bad("EEXISTS", `${full(r)} is a folder`, { names: [r] }); taken.push(r); if (conflict === "skip") step.skip = true; else if (conflict === "replace") step.replace = true; else if (conflict === "keep") { const ext = name.startsWith(".") ? "" : path.extname(name); const stem = ext ? name.slice(0, -ext.length) : name; for (let i = 1; ; i++) { if (i >= 10_000) throw bad("EEXISTS", `no free name for ${name}`); const n = `${stem} (${i})${ext}`; const abs = path.join(up.abs, n); const nRel = upRel ? `${upRel}/${n}` : n; if (claimed.has(abs) || own.has(nRel)) continue; if (await fs.promises.lstat(abs).catch(() => null)) continue; name = n; break; } } } step.name = name; step.abs = path.join(up.abs, name); step.full = up.rel ? `${up.rel}/${name}` : name; claimed.add(step.abs); out.push(step); } for (const r of seen) { if (dirs.has(r)) throw bad("EBADPATH", `${r} is both a file and a folder`); } return { base, make, files: out, taken }; } /** * `{taken, batch}` for a batch about to be sent: the names already in * `dirRel` (relative to it), and whether a batch can be written there at all * (false: send the files one by one). */ async function uploadCheck(dir, dirRel, paths) { if (!Array.isArray(paths) || !paths.every((p) => typeof p === "string")) { throw bad("EBADBODY", "paths is a list of relative paths"); } const files = batchFiles(paths.map((p) => ({ path: p, size: 0 }))); const plan = await planBatch(dir, dirRel, files, "fail"); let batch = true; try { needExtract(accountAt(plan.base.abs)); } catch { batch = false; } return { taken: plan.taken, batch }; } /** One octal tar field of `len` bytes. */ function tarOctal(buf, off, len, n) { buf.write(`${n.toString(8).padStart(len - 1, "0")}\0`, off, len, "ascii"); } /** A ustar header block for `name` (a pax `path` record first when it is long). */ function tarHeader(name, { type, size, mode, mtime }) { const block = (n, t, sz) => { const h = Buffer.alloc(512); h.write(n, 0, 100, "utf8"); tarOctal(h, 100, 8, mode); tarOctal(h, 108, 8, 0); tarOctal(h, 116, 8, 0); tarOctal(h, 124, 12, sz); tarOctal(h, 136, 12, Math.floor(mtime / 1000)); h.fill(" ", 148, 156); h.write(t, 156, 1, "ascii"); h.write("ustar\0" + "00", 257, 8, "ascii"); let sum = 0; for (const b of h) sum += b; h.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii"); return h; }; if (Buffer.byteLength(name) <= 100) return block(name, type, size); // " path=\n", where counts its own digits. const body = ` path=${name}\n`; let len = Buffer.byteLength(body); while (String(len).length + Buffer.byteLength(body) !== len) { len = String(len).length + Buffer.byteLength(body); } const rec = Buffer.from(`${len}${body}`, "utf8"); return Buffer.concat([block("PaxHeader", "x", rec.length), tarPad(rec), block("", type, size)]); } function tarPad(buf) { const rest = buf.length % 512; return rest ? Buffer.concat([buf, Buffer.alloc(512 - rest)]) : buf; } /** * Write a batch streamed from `stream` (see above) into `dirRel`. Returns * `{saved, skipped, paths}`; under `conflict: "fail"` a taken name is 409 * EEXISTS with every taken path in `names`. */ async function uploadBatch(dir, dirRel, stream, { conflict, trash } = {}) { const reader = byteReader(stream); try { const n = (await reader.read(4)).readUInt32BE(0); if (n > BATCH_HEAD_MAX) throw bad("ETOOBIG", "a batch's file list is too long"); let head; try { head = JSON.parse((await reader.read(n)).toString("utf8")); } catch (e) { if (e.code) throw e; throw bad("EBADBODY", "a batch starts with its file list as JSON"); } const files = batchFiles(head?.files); const policy = conflictOf(conflict); const plan = await planBatch(dir, dirRel, files, policy); if (policy === "fail" && plan.taken.length) { throw bad("EEXISTS", `${plan.taken.length} name(s) already exist`, { names: plan.taken }); } const acct = accountAt(plan.base.abs); needExtract(acct); for (const f of plan.files) { if (f.replace) await trash(f.full); } const done = []; let skipped = 0; if (acct) { const tar = new PassThrough(); const put = async (buf) => { // write-as gone (it failed): a destroyed stream never drains. if (tar.destroyed) throw bad("EWRITEAS", "the batch could not be written"); if (!tar.write(buf)) await once(tar, "drain"); }; const now = Date.now(); const extract = runAs.writeAsAsync(acct, plan.base.abs, ["extract"], { input: tar }); const feed = (async () => { for (const d of plan.make) { const name = `${path.relative(plan.base.abs, d.abs).split(path.sep).join("/")}/`; await put(tarHeader(name, { type: "5", size: 0, mode: 0o755, mtime: now })); } for (const f of plan.files) { const data = await reader.read(f.size); if (f.skip) { skipped++; continue; } const name = path.relative(plan.base.abs, f.abs).split(path.sep).join("/"); await put(tarHeader(name, { type: "0", size: f.size, mode: 0o644, mtime: f.mtime })); await put(tarPad(data)); done.push(f.full); } tar.end(Buffer.alloc(1024)); })(); await Promise.all([ feed.catch((e) => { tar.destroy(e); throw e; }), extract.catch((e) => { tar.destroy(e); throw e; }), ]); } else { for (const d of plan.make) { await fs.promises.mkdir(d.abs).catch(async (e) => { const st = e.code === "EEXIST" && (await fs.promises.lstat(d.abs).catch(() => null)); if (!st?.isDirectory()) throw e; }); } for (const f of plan.files) { const data = await reader.read(f.size); if (f.skip) { skipped++; continue; } const tmp = path.join( f.up.abs, `.${f.name}.agenthub-upload-${crypto.randomBytes(4).toString("hex")}` ); try { await fs.promises.writeFile(tmp, data, { flag: "wx" }); const t = new Date(f.mtime); await fs.promises.utimes(tmp, t, t); // Linked, not renamed: a name taken meanwhile is refused, never replaced. await fs.promises.link(tmp, f.abs).catch(async (e) => { if (e.code === "EEXIST") { throw bad("EEXISTS", `${f.rel} already exists`, { names: [f.rel] }); } if (e.code === "EPERM" || e.code === "ENOTSUP") return fs.promises.rename(tmp, f.abs); throw e; }); } finally { await fs.promises.rm(tmp, { force: true }); } done.push(f.full); } } return { saved: done.length, skipped, paths: done }; } catch (e) { await reader.drain().catch(() => {}); throw e; } } /** * 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, listUserTrash, restoreUserTrash, purgeUserTrash, restore, purgeTrash, upload, uploadBatch, uploadCheck, zip, collect, UPLOAD_MAX_BYTES, ZIP_MAX_BYTES, TRASH_DAYS, };