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
@@ -155,6 +155,23 @@ describe("server mode", () => {
);
});
it("a member with no folder of their own may not make one anywhere (browse/mkdir)", async () => {
for (const as of ["alice", "ana"]) {
const r = await call("POST", "/api/lanes/browse/mkdir", {
as,
body: { parent: ROOT, name: `by-${as}` },
});
assert.equal(r.status, 403, JSON.stringify(r.body));
assert.equal(r.body.error.code, "EFOLDER");
}
assert.ok(!fs.existsSync(path.join(ROOT, "by-alice")));
const admin = await call("POST", "/api/lanes/browse/mkdir", {
as: "boss",
body: { parent: ROOT, name: "by-boss" },
});
assert.equal(admin.status, 201, JSON.stringify(admin.body));
});
it("pipeline stats cover only a member's own workspaces", async () => {
const mine = await get("/api/lanes/pipeline-stats", "alice");
assert.deepEqual(