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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user