feat(server-mode): native-like folder browser, a developer kept to their own folder, no password minimum

- Folder browser (the Browse fallback for a remote browser): back/forward/up,
  a clickable breadcrumb that becomes a path field (Ctrl+L), Places (Home,
  the user's workspaces, recent picks), a filter, Name/Modified columns,
  Show hidden and New folder. A click selects; a double-click or Enter
  opens a folder or picks a file; Backspace goes up; typing jumps to a name.
- GET /api/lanes/browse: `hidden=1` (never the dot entries at the top of a
  non-admin's own folder), each entry's `mtime`, the answer's `home`.
- POST /api/lanes/browse/mkdir: one plain folder name inside an existing
  folder; a non-admin only in their own folder, none in server mode without
  one; under S3 a member's folder is made as the member.
- scope.ownFolder falls back to server.conf's USERS_ROOT under S3, so a
  settings file without AGENTHUB_USERS_ROOT no longer opens the host's home
  to every developer.
- Passwords: any non-empty password is accepted (no 8-character minimum).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 14:43:03 +07:00
parent b0dd5af22f
commit 763759189f
22 changed files with 1174 additions and 110 deletions
+94 -6
View File
@@ -566,6 +566,9 @@ router.post("/gc", sameOriginGuard, (req, res) => {
* "/:id" so the literal "browse" segment is never captured as a lane id.
* `files=1` (a file field's fallback) lists files too, and opens `path` the
* way the native picker would (`startDir`: `~`, a file's folder, else home).
* `hidden=1` lists dot entries too — never those at the top of a non-admin's
* own folder, where a member's logins live. Each entry carries its `mtime`
* (ms), and `home` is the folder the browser's Home place opens.
*/
router.get("/browse", (req, res) => {
// A developer browses only their own folder (by real path: a link in it
@@ -573,6 +576,7 @@ router.get("/browse", (req, res) => {
// rights, CAP_DAC_READ_SEARCH under S3, not theirs.
const own = scope.ownFolder(req);
const files = req.query.files === "1";
const hidden = req.query.hidden === "1";
const raw =
typeof req.query.path === "string" && req.query.path.trim()
? files
@@ -594,15 +598,33 @@ router.get("/browse", (req, res) => {
.json({ error: { code: "ENOTADIR", message: "path is not a directory" } });
}
const atOwnTop = Boolean(own) && resolved === lanesLib.canonicalCwd(own);
const mtimeOf = (full) => {
try {
return fs.statSync(full).mtimeMs;
} catch {
return null;
}
};
let entries = [];
try {
entries = fs
.readdirSync(resolved, { withFileTypes: true })
.filter((e) => (e.isDirectory() || (files && e.isFile())) && !e.name.startsWith("."))
.filter(
(e) =>
(e.isDirectory() || (files && e.isFile())) &&
(!e.name.startsWith(".") || (hidden && !atOwnTop))
)
.map((e) => {
const full = path.join(resolved, e.name);
if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true };
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
const mtime = mtimeOf(full);
if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true, mtime };
return {
name: e.name,
path: full,
isGitRepo: fs.existsSync(path.join(full, ".git")),
mtime,
};
})
// Folders first, as every file picker lists them.
.sort((a, b) => !!a.file - !!b.file || a.name.localeCompare(b.name));
@@ -610,12 +632,78 @@ router.get("/browse", (req, res) => {
// An unreadable entry mid-listing is skipped, not a request failure.
}
const top =
path.dirname(resolved) === resolved || (own && resolved === lanesLib.canonicalCwd(own));
const top = path.dirname(resolved) === resolved || atOwnTop;
const parent = top ? null : path.dirname(resolved);
res.json({ path: resolved, parent, entries });
res.json({ path: resolved, parent, entries, home: own || os.homedir() });
});
/**
* The folder browser's New folder: makes ONE folder `name` (a plain name —
* no separator, not `.`/`..`) in the existing folder `parent`, never with
* `-p` and never over an existing entry (`EEXISTS`). A non-admin makes one
* only inside their own folder, judged by real path like `GET /browse`, and
* in server mode a non-admin with no folder of their own makes none
* (`EFOLDER`) — a read of the host's disk is not a write to it. A folder in a
* member's home (S3) is made AS the member, never by the host.
*/
router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
const { parent, name } = req.body || {};
if (
typeof name !== "string" ||
!name ||
name === "." ||
name === ".." ||
/[/\\\0]/.test(name) ||
Buffer.byteLength(name) > 255
) {
return res
.status(400)
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
}
if (typeof parent !== "string" || !path.isAbsolute(parent)) {
return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
}
const own = scope.ownFolder(req);
const limited = Boolean(req.user) && req.user.role !== "admin";
if (limited && !own) return refuseFolder(res);
const dir = own ? lanesLib.canonicalCwd(path.resolve(parent)) : path.resolve(parent);
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res);
try {
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
} catch {
return res
.status(400)
.json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
}
const target = path.join(dir, name);
if (fs.existsSync(target) || isSymlink(target)) {
return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
}
try {
const runAs = require("../lib/run-as");
const acct = runAs.accountForPath(target);
if (acct) runAs.mkdirAs(acct, target);
else fs.mkdirSync(target);
} catch (err) {
const status = err.code === "EMEMBERGONE" ? 409 : err.code === "EEXIST" ? 409 : 500;
return res.status(status).json({
error: {
code: err.code === "EEXIST" ? "EEXISTS" : err.code || "EMKDIR",
message: err.message,
},
});
}
res.status(201).json({ path: target });
});
function isSymlink(p) {
try {
return fs.lstatSync(p).isSymbolicLink();
} catch {
return false;
}
}
/**
* The OS's own folder/file picker, shown on the SERVER's desktop
* (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`.