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
+35
View File
@@ -598,6 +598,41 @@ describe("GET /api/lanes/browse", () => {
const dirsOnly = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
assert.ok(!dirsOnly.body.entries.some((e) => e.name === "b-file.txt"));
});
it("answers the home folder and each entry's mtime; hidden=1 lists dot entries", async () => {
fs.mkdirSync(path.join(ROOT, ".hidden-dir"), { recursive: true });
const plain = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
assert.equal(plain.body.home, os.homedir());
assert.ok(!plain.body.entries.some((e) => e.name === ".hidden-dir"));
const repo = plain.body.entries.find((e) => e.name === "src-repo");
assert.equal(typeof repo.mtime, "number");
const all = await request("GET", `/api/lanes/browse?hidden=1&path=${encodeURIComponent(ROOT)}`);
assert.ok(all.body.entries.some((e) => e.name === ".hidden-dir"));
});
});
describe("POST /api/lanes/browse/mkdir", () => {
it("makes one new folder in an existing one and answers its path", async () => {
const r = await request("POST", "/api/lanes/browse/mkdir", { parent: ROOT, name: "made" });
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.path, path.join(ROOT, "made"));
assert.ok(fs.statSync(path.join(ROOT, "made")).isDirectory());
const again = await request("POST", "/api/lanes/browse/mkdir", { parent: ROOT, name: "made" });
assert.equal(again.status, 409);
assert.equal(again.body.error.code, "EEXISTS");
});
it("refuses a name that is not one plain folder name, and a missing parent", async () => {
for (const name of ["", ".", "..", "a/b", "a\0b", "x".repeat(256), 5]) {
const r = await request("POST", "/api/lanes/browse/mkdir", { parent: ROOT, name });
assert.equal(r.status, 400, `${JSON.stringify(name)}: ${JSON.stringify(r.body)}`);
assert.equal(r.body.error.code, "EBADNAME");
}
for (const parent of ["relative/dir", path.join(ROOT, "nope")]) {
const r = await request("POST", "/api/lanes/browse/mkdir", { parent, name: "x" });
assert.equal(r.status, 400, JSON.stringify(r.body));
}
});
});
describe("destructive lane lifecycle actions", () => {
+58
View File
@@ -0,0 +1,58 @@
/**
* @file scope-users-root.test.js
* @description A non-admin's own folder comes from `AGENTHUB_USERS_ROOT`, and
* under S3 from server.conf's `USERS_ROOT` when that variable is missing — a
* server with S3 installed but no variable in its settings file let every
* developer browse the host account's whole home.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "scope-users-root-")));
const CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_SERVER_CONF = CONF;
process.env.AGENTHUB_MODE = "server";
delete process.env.AGENTHUB_USERS_ROOT;
const scope = require("../lib/scope");
const linux = require("../lib/linux-accounts");
const dev = { user: { id: 2, username: "vinnt", role: "developer" } };
const admin = { user: { id: 1, username: "boss", role: "admin" } };
describe("scope.ownFolder", () => {
before(() => {
fs.writeFileSync(CONF, "HOST_USER=host\nHOST_UID=1000\nUSERS_ROOT=/srv/agenthub/users\n");
linux.__resetConfCache();
});
afterEach(() => {
delete process.env.AGENTHUB_USERS_ROOT;
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
it("falls back to server.conf's USERS_ROOT under S3", () => {
assert.equal(scope.ownFolder(dev), "/srv/agenthub/users/vinnt");
assert.equal(scope.folderAllowed(dev, os.homedir()), false);
assert.equal(scope.folderAllowed(dev, "/srv/agenthub/users/vinnt/proj"), true);
});
it("prefers AGENTHUB_USERS_ROOT when it is set", () => {
process.env.AGENTHUB_USERS_ROOT = "/data/users";
assert.equal(scope.ownFolder(dev), "/data/users/vinnt");
});
it("never limits an admin", () => {
assert.equal(scope.ownFolder(admin), null);
});
it("has no folder without S3 or the variable", () => {
fs.rmSync(CONF);
linux.__resetConfCache();
assert.equal(scope.ownFolder(dev), null);
});
});
@@ -94,12 +94,18 @@ describe("managing accounts from the dashboard", () => {
});
assert.equal(dup.status, 409);
assert.equal(dup.body.error.code, "EEXISTS");
// No minimum length — only an empty password is refused.
const empty = await call("POST", "/api/users", {
cookie: boss,
body: { username: "zed", password: "", currentPassword: BOSS_PASS },
});
assert.equal(empty.status, 400);
assert.equal(empty.body.error.code, "EBADPASSWORD");
const short = await call("POST", "/api/users", {
cookie: boss,
body: { username: "zed", password: "short", currentPassword: BOSS_PASS },
body: { username: "zed", password: "1", currentPassword: BOSS_PASS },
});
assert.equal(short.status, 400);
assert.equal(short.body.error.code, "EBADPASSWORD");
assert.equal(short.status, 201);
});
it("refuses without the admin's password, or with a wrong one", async () => {
@@ -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(
@@ -226,6 +226,37 @@ describe("a developer browses and lists branches only in their own folder", () =
assert.equal(admin.status, 200);
});
it("hidden=1 shows dot entries, never the logins at the top of their home", async () => {
fs.mkdirSync(path.join(MIA, ".claude"), { recursive: true });
fs.mkdirSync(path.join(MIA, "proj", ".cfg"), { recursive: true });
const top = await call("mia", "GET", "/api/lanes/browse?hidden=1");
assert.equal(top.status, 200, JSON.stringify(top.body));
assert.equal(top.body.home, MIA);
assert.ok(!top.body.entries.some((e) => e.name.startsWith(".")));
const inner = await call(
"mia",
"GET",
`/api/lanes/browse?hidden=1&path=${encodeURIComponent(path.join(MIA, "proj"))}`
);
assert.ok(inner.body.entries.some((e) => e.name === ".cfg"));
});
it("mkdir: only in their own folder, made as them", async () => {
const mine = await call("mia", "POST", "/api/lanes/browse/mkdir", {
parent: MIA,
name: "fresh",
});
assert.equal(mine.status, 201, JSON.stringify(mine.body));
assert.ok(fs.statSync(path.join(MIA, "fresh")).isDirectory());
for (const parent of [BOB, path.join(MIA, "tohost")]) {
const r = await call("mia", "POST", "/api/lanes/browse/mkdir", { parent, name: "x" });
assert.equal(r.status, 403, `${parent}: ${JSON.stringify(r.body)}`);
assert.equal(r.body.error.code, "EFOLDER");
}
assert.ok(!fs.existsSync(path.join(BOB, "x")));
assert.ok(!fs.existsSync(path.join(HOSTDIR, "x")));
});
it("branches", async () => {
const other = await call(
"mia",