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:
2026-10-14 14:17:39 +07:00
parent b441f32949
commit e94038481e
38 changed files with 5961 additions and 27 deletions
+57
View File
@@ -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,