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:
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-3
@@ -39,7 +39,6 @@ const COOKIE = "agenthub_sid";
|
||||
const SESSION_DAYS = 30;
|
||||
const ROLES = ["admin", "analyst", "developer"];
|
||||
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
/**
|
||||
* API prefixes whose writes change the host account's own setup. (AI Proxy
|
||||
@@ -120,8 +119,9 @@ function validateUsername(name) {
|
||||
}
|
||||
|
||||
function validatePassword(password) {
|
||||
if (typeof password !== "string" || password.length < MIN_PASSWORD) {
|
||||
throw err("EBADPASSWORD", `password: at least ${MIN_PASSWORD} characters`);
|
||||
// No minimum length (the operator's choice) — only an empty one is refused.
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
throw err("EBADPASSWORD", "password: required");
|
||||
}
|
||||
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
|
||||
}
|
||||
|
||||
+8
-2
@@ -289,12 +289,18 @@ function audienceIncludes(audience, user) {
|
||||
|
||||
/**
|
||||
* A non-admin's own folder, `<AGENTHUB_USERS_ROOT>/<username>`, or null (no
|
||||
* root set, or an admin). Whatever the method: an analyst reads every user's
|
||||
* root set, or an admin). Under S3 a missing variable falls back to
|
||||
* server.conf's `USERS_ROOT` — the folder the member's Linux account lives in —
|
||||
* so a settings file without it can never open the host's whole disk to them.
|
||||
* Whatever the method: an analyst reads every user's
|
||||
* ROWS, but a path named in a request is read with the dashboard's own rights
|
||||
* (CAP_DAC_READ_SEARCH under S3), so an analyst's GET is held to it too.
|
||||
*/
|
||||
function ownFolder(req) {
|
||||
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
|
||||
const linux = require("./linux-accounts");
|
||||
const root =
|
||||
(process.env.AGENTHUB_USERS_ROOT || "").trim() ||
|
||||
(linux.s3Enabled() ? linux.serverConf().usersRoot : "");
|
||||
if (!req?.user || req.user.role === "admin" || !root) return null;
|
||||
const path = require("node:path");
|
||||
return path.join(path.resolve(root), req.user.username);
|
||||
|
||||
@@ -241,7 +241,7 @@ const paths = {
|
||||
required: ["username", "password", "currentPassword"],
|
||||
properties: {
|
||||
username: { type: "string" },
|
||||
password: { type: "string", minLength: 8 },
|
||||
password: { type: "string", minLength: 1 },
|
||||
role: {
|
||||
type: "string",
|
||||
enum: ["admin", "analyst", "developer"],
|
||||
@@ -282,7 +282,7 @@ const paths = {
|
||||
operationId: "resetServerUserPassword",
|
||||
summary: "Admin: a new password for that user; every login of theirs ends",
|
||||
parameters: [idParam],
|
||||
requestBody: changeBody({ password: { type: "string", minLength: 8 } }, ["password"]),
|
||||
requestBody: changeBody({ password: { type: "string", minLength: 1 } }, ["password"]),
|
||||
responses: changeResponses,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -151,6 +151,42 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/browse/mkdir": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "New folder, from the in-app folder browser",
|
||||
description:
|
||||
"Makes ONE folder `name` (no `/`, `\\`, NUL, `.` or `..`; ≤ 255 bytes) inside the existing folder `parent` — never `-p`, never over an existing entry. A non-admin makes one only inside their own folder, by real path (`EFOLDER`), and in server mode a non-admin with no folder of their own makes none. Under S3 a folder in a member's home is made as that member.",
|
||||
operationId: "makeBrowseFolder",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["parent", "name"],
|
||||
properties: {
|
||||
parent: { type: "string", description: "Absolute path of an existing folder." },
|
||||
name: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "`{path}` — the folder made.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { path: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { description: "EBADNAME, EBADPATH, or ENOTFOUND (no such parent folder)." },
|
||||
403: { description: "EBADORIGIN, or EFOLDER (outside the caller's own folder)." },
|
||||
409: { description: "EEXISTS (the name is taken) or EMEMBERGONE." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/ensure": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
|
||||
+94
-6
@@ -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}`.
|
||||
|
||||
Reference in New Issue
Block a user