From 763759189f6806cae46bb12c68c657d87ff78e51 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 13 Oct 2026 14:43:03 +0700 Subject: [PATCH] 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 --- .../__tests__/PathPickButton.test.tsx | 10 +- .../components/lanes/FolderBrowseModal.tsx | 684 ++++++++++++++++-- .../lanes/__tests__/AddLaneModal.test.tsx | 8 +- .../__tests__/FolderBrowseModal.test.tsx | 178 +++++ client/src/i18n/locales/en/lanes.json | 18 + client/src/i18n/locales/en/users.json | 4 +- client/src/i18n/locales/vi/lanes.json | 18 + client/src/i18n/locales/vi/users.json | 4 +- client/src/lib/api.ts | 26 +- docs/API.md | 14 +- docs/LANES.md | 2 +- docs/SERVER-MODE.md | 9 +- server/__tests__/lane-lifecycle.test.js | 35 + server/__tests__/scope-users-root.test.js | 58 ++ .../__tests__/server-mode-user-admin.test.js | 12 +- .../__tests__/server-mode-workspaces.test.js | 17 + server/__tests__/server-s3-review2.test.js | 31 + server/lib/auth.js | 6 +- server/lib/scope.js | 10 +- server/openapi-extra/auth.js | 4 +- server/openapi-extra/lanes.js | 36 + server/routes/lanes.js | 100 ++- 22 files changed, 1174 insertions(+), 110 deletions(-) create mode 100644 client/src/components/lanes/__tests__/FolderBrowseModal.test.tsx create mode 100644 server/__tests__/scope-users-root.test.js diff --git a/client/src/components/__tests__/PathPickButton.test.tsx b/client/src/components/__tests__/PathPickButton.test.tsx index bbe03e4a..18766d36 100644 --- a/client/src/components/__tests__/PathPickButton.test.tsx +++ b/client/src/components/__tests__/PathPickButton.test.tsx @@ -3,7 +3,7 @@ * @description Unit tests for the Browse button beside path fields: a chosen * path fills the field, a Cancel changes nothing, and a folder or a file falls * back to the in-app browser on ANY native-picker failure (a LAN browser's - * EREMOTE included). + * EREMOTE included), where a double-click on a file picks it. * @author Nguyễn Ngọc Trí Vĩ */ @@ -15,7 +15,7 @@ import { toast } from "../../lib/toast"; import { PathPickButton } from "../PathPickButton"; vi.mock("../../lib/api", () => ({ - api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } }, + api: { lanes: { pickPath: vi.fn(), browse: vi.fn(), list: vi.fn(() => new Promise(() => {})) } }, })); vi.mock("../../lib/toast", () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() }, @@ -55,7 +55,7 @@ describe("PathPickButton", () => { expect(toast.error).not.toHaveBeenCalled(); }); - it("opens the in-app browser for a file too, and a click on a file picks it", async () => { + it("opens the in-app browser for a file too, and a double-click on a file picks it", async () => { const onPick = vi.fn(); vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE")); vi.mocked(api.lanes.browse).mockResolvedValue({ @@ -68,9 +68,9 @@ describe("PathPickButton", () => { render(); await userEvent.click(screen.getByRole("button")); const dialog = await screen.findByRole("dialog", { name: "Browse for a file" }); - expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true); + expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false); expect(within(dialog).queryByText("Select this folder")).toBeNull(); - await userEvent.click(await within(dialog).findByText("id_ed25519")); + await userEvent.dblClick(await within(dialog).findByText("id_ed25519")); expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519"); expect(screen.queryByRole("dialog")).toBeNull(); expect(toast.info).not.toHaveBeenCalled(); diff --git a/client/src/components/lanes/FolderBrowseModal.tsx b/client/src/components/lanes/FolderBrowseModal.tsx index e1bf6c5e..d9c2ceed 100644 --- a/client/src/components/lanes/FolderBrowseModal.tsx +++ b/client/src/components/lanes/FolderBrowseModal.tsx @@ -1,20 +1,97 @@ /** * @file A server-backed folder (or file) browser for the path inputs' Browse - * button. Browsers refuse to expose an absolute filesystem path from a native - * picker, so path selection here is done by browsing `GET /api/lanes/browse` - * (immediate subdirectories of a path, plus its files for `kind="file"`) - * instead — breadcrumb-free, just an up-one-level button and a - * click-to-descend list; in file mode a click on a file picks it. + * button, laid out like the OS's own picker. Browsers refuse to expose an + * absolute filesystem path from a native picker, so path selection here is + * done by browsing `GET /api/lanes/browse` instead: back/forward/up, a + * clickable breadcrumb that turns into a path field (Ctrl+L), a Places column + * (Home, the user's workspaces, recent picks), a filter, Name/Modified columns, + * hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`). + * A click selects, a double-click or Enter opens a folder (picks a file), + * Backspace goes up and typing jumps to a name. * @author Nguyễn Ngọc Trí Vĩ */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; -import { FolderOpen, FolderGit2, ArrowUp, File } from "lucide-react"; +import { + ArrowLeft, + ArrowRight, + ArrowUp, + Clock, + Eye, + File, + FolderGit2, + FolderOpen, + FolderPlus, + Home, + Pencil, + Star, +} from "lucide-react"; import { api } from "../../lib/api"; +import { formatDateTime } from "../../lib/format"; +import { isSubmitEnter } from "../../lib/keys"; import { useDialog } from "../../hooks/useDialog"; +import { useWorkLanes } from "../../hooks/useLanes"; -export function FolderBrowseModal({ +type Listing = Awaited>; +type Entry = Listing["entries"][number]; + +const RECENT_KEY = "agenthub.browse.recent"; +const HIDDEN_KEY = "agenthub.browse.hidden"; +const RECENT_MAX = 8; + +function readStored(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + +function writeStored(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // A private window or blocked storage: the preference just isn't kept. + } +} + +/** `[label, path]` for each level of `p`, root first. */ +function crumbs(p: string): [string, string][] { + const win = /^[A-Za-z]:\\/.test(p); + const sep = win ? "\\" : "/"; + const parts = p.split(sep).filter(Boolean); + const out: [string, string][] = win ? [] : [["/", "/"]]; + let acc = ""; + for (const part of parts) { + acc = win ? (acc ? `${acc}\\${part}` : `${part}\\`) : `${acc}/${part}`; + out.push([part, acc]); + } + return out; +} + +const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() || p; + +type Props = { + open: boolean; + /** `file` lists files too, and picking one is the only way to select. */ + kind?: "dir" | "file"; + /** Path to start browsing from; omitted defaults server-side to the home dir. */ + initialPath?: string; + onSelect: (path: string) => void; + onClose: () => void; +}; + +/** + * Mounted only while open, so a closed picker (one behind every Browse + * button) asks the server for nothing, and each opening starts fresh. + */ +export function FolderBrowseModal(props: Props) { + return props.open ? : null; +} + +function BrowseDialog({ open, kind = "dir", initialPath, @@ -30,20 +107,62 @@ export function FolderBrowseModal({ onClose: () => void; }) { const { t } = useTranslation(["lanes"]); - const [listing, setListing] = useState> | null>(null); + const { lanes } = useWorkLanes(); + const [listing, setListing] = useState(null); const [error, setError] = useState(null); + const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 }); + const [selected, setSelected] = useState(null); + const [filter, setFilter] = useState(""); + const [editing, setEditing] = useState(false); + const [pathText, setPathText] = useState(""); + const [sort, setSort] = useState<{ key: "name" | "mtime"; dir: 1 | -1 }>({ + key: "name", + dir: 1, + }); + const [showHidden, setShowHidden] = useState(() => readStored(HIDDEN_KEY, false)); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [recent, setRecent] = useState(() => readStored(RECENT_KEY, [])); + // The highest folder this user may open (a member's own folder answers no + // parent) — breadcrumbs above it are not links. + const [floor, setFloor] = useState(null); + const [home, setHome] = useState(null); + const listRef = useRef(null); + const crumbRef = useRef(null); + const typeAhead = useRef({ text: "", at: 0 }); - const load = async (path?: string) => { + const load = async ( + target?: string, + { push = true, hidden = showHidden, select = null as string | null } = {} + ) => { setError(null); try { - setListing(await (kind === "file" ? api.lanes.browse(path, true) : api.lanes.browse(path))); + const next = await api.lanes.browse(target, kind === "file", hidden); + setListing(next); + setSelected(select); + setFilter(""); + setEditing(false); + if (next.home) setHome(next.home); + if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path); + if (push) { + setHistory((h) => { + if (h.stack[h.at] === next.path) return h; + const stack = [...h.stack.slice(0, h.at + 1), next.path]; + return { stack, at: stack.length - 1 }; + }); + } + return true; } catch (err) { setError(err instanceof Error ? err.message : String(err)); + return false; } }; useEffect(() => { - if (open) void load(initialPath); + if (!open) return; + setHistory({ stack: [], at: -1 }); + setCreating(false); + void load(initialPath); // Only re-run when the modal actually opens - not on every initialPath // keystroke in the field behind it. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -54,8 +173,164 @@ export function FolderBrowseModal({ const panel = useRef(null); useDialog(open, panel, onClose); + const entries = useMemo(() => { + const needle = filter.trim().toLowerCase(); + const list = (listing?.entries ?? []).filter( + (e) => !needle || e.name.toLowerCase().includes(needle) + ); + const by = (a: Entry, b: Entry) => + sort.key === "mtime" + ? ((a.mtime ?? 0) - (b.mtime ?? 0)) * sort.dir + : a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) * + sort.dir; + // Folders first, as every file picker lists them. + return [...list].sort((a, b) => Number(!!a.file) - Number(!!b.file) || by(a, b)); + }, [listing, filter, sort]); + + const places = useMemo(() => { + const seen = new Set(); + return lanes + .filter((l) => l.cwd && !seen.has(l.cwd) && seen.add(l.cwd)) + .map((l) => ({ label: l.title || baseName(l.cwd), path: l.cwd })); + }, [lanes]); + + // A deep path keeps its LAST levels in view, like a native path bar. + useEffect(() => { + const el = crumbRef.current; + if (el) el.scrollLeft = el.scrollWidth; + }, [listing?.path, editing]); + + useEffect(() => { + if (!selected) return; + const el = Array.from(listRef.current?.querySelectorAll("[data-path]") ?? []).find( + (x) => x.dataset.path === selected + ); + el?.scrollIntoView?.({ block: "nearest" }); + }, [selected]); + if (!open) return null; + const finish = (picked: string) => { + const folder = kind === "file" ? (listing?.path ?? picked) : picked; + const next = [folder, ...recent.filter((r) => r !== folder)].slice(0, RECENT_MAX); + setRecent(next); + writeStored(RECENT_KEY, next); + onSelect(picked); + onClose(); + }; + + const activate = (entry: Entry) => { + if (entry.file) finish(entry.path); + else void load(entry.path); + }; + + const go = (delta: -1 | 1) => { + const at = history.at + delta; + const target = history.stack[at]; + if (target === undefined) return; + void load(target, { push: false }).then((ok) => { + if (ok) setHistory((h) => ({ ...h, at })); + }); + }; + + const goUp = () => { + if (!listing?.parent) return; + const from = listing.path; + void load(listing.parent, { select: from }); + }; + + const selectedEntry = entries.find((e) => e.path === selected) ?? null; + const target = + kind === "file" + ? selectedEntry?.file + ? selectedEntry.path + : null + : selectedEntry && !selectedEntry.file + ? selectedEntry.path + : (listing?.path ?? null); + + const onListKey = (e: KeyboardEvent) => { + const idx = entries.findIndex((x) => x.path === selected); + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + if (e.altKey && e.key === "ArrowUp") return goUp(); + const next = + e.key === "ArrowDown" ? Math.min(entries.length - 1, idx + 1) : Math.max(0, idx - 1); + if (entries[next]) setSelected(entries[next].path); + return; + } + if (e.key === "Home" || e.key === "End") { + e.preventDefault(); + const pick = e.key === "Home" ? entries[0] : entries[entries.length - 1]; + if (pick) setSelected(pick.path); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + if (selectedEntry) activate(selectedEntry); + else if (kind === "dir" && target) finish(target); + return; + } + if (e.key === "Backspace") { + e.preventDefault(); + goUp(); + return; + } + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + // Typing jumps to the first name that starts with what was typed. + const now = Date.now(); + const ta = typeAhead.current; + ta.text = now - ta.at > 800 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase(); + ta.at = now; + const hit = entries.find((x) => x.name.toLowerCase().startsWith(ta.text)); + if (hit) setSelected(hit.path); + } + }; + + const onPanelKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") { + e.preventDefault(); + setPathText(listing?.path ?? ""); + setEditing(true); + } else if (e.altKey && e.key === "ArrowLeft") { + e.preventDefault(); + go(-1); + } else if (e.altKey && e.key === "ArrowRight") { + e.preventDefault(); + go(1); + } + }; + + const createFolder = async () => { + const name = newName.trim(); + if (!name || !listing) return; + setError(null); + try { + const made = await api.lanes.mkdir(listing.path, name); + setCreating(false); + setNewName(""); + await load(listing.path, { push: false, select: made.path }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const toggleHidden = () => { + const next = !showHidden; + setShowHidden(next); + writeStored(HIDDEN_KEY, next); + if (listing) void load(listing.path, { push: false, hidden: next, select: selected }); + }; + + const iconBtn = + "flex h-7 w-7 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-2 disabled:opacity-40 disabled:hover:bg-transparent"; + const placeBtn = (active: boolean) => + `flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs ${ + active ? "bg-surface-2 text-fg-primary" : "text-fg-secondary hover:bg-surface-2" + }`; + const path = listing?.path ?? ""; + const floorDepth = floor ? crumbs(floor).length : 0; + return (
e.stopPropagation()} + onKeyDown={onPanelKey} role="dialog" aria-modal="true" aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")} data-tour="folder-browse" > -
-
- {listing?.path || "…"} + {/* Toolbar: history, up, and the breadcrumb / path field. */} +
+ + + + {editing ? ( + setPathText(e.target.value)} + onKeyDown={(e) => { + if (isSubmitEnter(e)) { + e.preventDefault(); + if (pathText.trim()) void load(pathText.trim()); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setEditing(false); + } + }} + onBlur={() => setEditing(false)} + aria-label={t("browse.editPath")} + spellCheck={false} + className="input min-w-0 flex-1 font-mono text-xs" + /> + ) : ( +
{ + setPathText(path); + setEditing(true); + }} + > + {crumbs(path).map(([label, p], i, all) => { + const last = i === all.length - 1; + const locked = i + 1 < floorDepth; + return ( + + {i > 0 && all[i - 1]?.[0] !== "/" && ( + › + )} + + + ); + })} +
+ )} + +
+ +
+ {/* Places */} + + + {/* Listing */} +
+
+ setFilter(e.target.value)} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (entries[0]) setSelected(entries[0].path); + listRef.current?.focus(); + } else if (isSubmitEnter(e) && entries.length === 1 && entries[0]) { + e.preventDefault(); + activate(entries[0]); + } + }} + placeholder={t("browse.filter")} + aria-label={t("browse.filter")} + className="input min-w-0 flex-1 text-xs" + /> +
+
+ {(["name", "mtime"] as const).map((key) => ( + + ))} +
+
+ {error && ( +

+ {error} +

+ )} + {creating && ( +
+ + setNewName(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (isSubmitEnter(e)) { + e.preventDefault(); + void createFolder(); + } else if (e.key === "Escape") { + e.preventDefault(); + setCreating(false); + } + }} + placeholder={t("browse.newFolderName")} + aria-label={t("browse.newFolderName")} + className="input min-w-0 flex-1 text-xs" + /> + +
+ )} + {entries.map((entry) => { + const isSel = entry.path === selected; + return ( +
setSelected(entry.path)} + onDoubleClick={() => activate(entry)} + className={`flex cursor-default select-none items-center gap-2 rounded px-2 py-1.5 text-xs ${ + isSel ? "bg-accent-solid text-white" : "text-fg-primary hover:bg-surface-2" + }`} + > + {entry.file ? ( + + ) : entry.isGitRepo ? ( + + ) : ( + + )} + {entry.name} + + {entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""} + +
+ ); + })} + {listing && entries.length === 0 && ( +

+ {filter.trim() ? t("browse.noMatch") : t("browse.empty")} +

+ )} +
-
- {error && ( -

- {error} -

- )} - {listing?.parent && ( - - )} - {listing?.entries.map((entry) => ( - - ))} - {listing && listing.entries.length === 0 && !listing.parent && ( -

{t("browse.empty")}

- )} -
- -
- - {kind === "dir" && ( + {target ?? "…"} +
+
- )} + + + {t("browse.hint")} + +
+ + +
+
diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx index a3615b28..30481046 100644 --- a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -27,6 +27,8 @@ vi.mock("../../../lib/api", () => ({ ensure: vi.fn(), browse: vi.fn(), pickPath: vi.fn(), + // The folder browser's Places list the user's workspaces. + list: vi.fn(() => Promise.resolve({ lanes: [], counts: {} })), get: vi.fn(), profileInit: vi.fn(), mcpSync: vi.fn(), @@ -614,7 +616,7 @@ describe("AddLaneModal — folder browse", () => { expect(onClose).not.toHaveBeenCalled(); }); - it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => { + it("opens the browser, lists subdirectories, and a double-click opens one", async () => { noNativePicker(); vi.mocked(api.lanes.browse).mockResolvedValue({ path: "/Users/tester", @@ -627,8 +629,8 @@ describe("AddLaneModal — folder browse", () => { await user.click(screen.getByTitle("Browse for a folder")); expect(await screen.findByText("projects")).toBeInTheDocument(); - await user.click(screen.getByText("projects")); - expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects"); + await user.dblClick(screen.getByText("projects")); + expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false); }); it("Escape closes only the folder browser, not the whole modal", async () => { diff --git a/client/src/components/lanes/__tests__/FolderBrowseModal.test.tsx b/client/src/components/lanes/__tests__/FolderBrowseModal.test.tsx new file mode 100644 index 00000000..0f9ed828 --- /dev/null +++ b/client/src/components/lanes/__tests__/FolderBrowseModal.test.tsx @@ -0,0 +1,178 @@ +/** + * @file FolderBrowseModal.test.tsx + * @description Unit tests for the in-app folder browser laid out like a + * native picker: a click selects and Select picks that folder (else the + * current one), a double-click opens, Backspace and the breadcrumb go up, + * the filter narrows the list, Places open Home and a workspace, the path + * field (Ctrl+L) opens a typed path, New folder makes one and selects it, + * Show hidden re-lists with dot entries, and a Select is remembered as Recent. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { api } from "../../../lib/api"; +import { FolderBrowseModal } from "../FolderBrowseModal"; + +vi.mock("../../../lib/api", () => ({ + api: { lanes: { browse: vi.fn(), mkdir: vi.fn(), list: vi.fn() } }, +})); + +const TREE: Record = { + "/home/u": ["proj", "notes"], + "/home/u/proj": ["api", "web"], + "/home/u/proj/api": [], + "/home/u/proj/web": [], + "/home/u/notes": [], + "/srv/ws": [], +}; + +function listing(p: string, hidden = false) { + const names = [...(TREE[p] ?? []), ...(hidden ? [".cfg"] : [])]; + return { + path: p, + parent: p === "/home/u" ? null : p.slice(0, p.lastIndexOf("/")) || "/", + home: "/home/u", + entries: names.map((n, i) => ({ + name: n, + path: `${p}/${n}`, + isGitRepo: n === "api", + mtime: 1_700_000_000_000 + i, + })), + }; +} + +beforeEach(() => { + localStorage.clear(); + vi.mocked(api.lanes.browse) + .mockReset() + .mockImplementation(async (p?: string, _f?: boolean, hidden?: boolean) => { + const at = p || "/home/u"; + if (!(at in TREE)) throw new Error("path does not exist"); + return listing(at, hidden); + }); + vi.mocked(api.lanes.mkdir).mockReset(); + vi.mocked(api.lanes.list) + .mockReset() + .mockResolvedValue({ + lanes: [{ id: 1, title: "Shop API", cwd: "/srv/ws" }], + counts: {}, + } as never); +}); + +const open = (onSelect = vi.fn(), initialPath?: string) => { + render( + + ); + return screen.findByRole("dialog", { name: "Browse for a folder" }); +}; + +const option = (dialog: HTMLElement, name: string) => + within(dialog).findByRole("option", { name: new RegExp(name) }); + +describe("FolderBrowseModal", () => { + it("click selects, double-click opens, Select picks the selection else the folder", async () => { + const onSelect = vi.fn(); + const dialog = await open(onSelect); + await userEvent.click(await option(dialog, "proj")); + expect(await option(dialog, "proj")).toHaveAttribute("aria-selected", "true"); + await userEvent.dblClick(await option(dialog, "proj")); + await option(dialog, "web"); + await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" })); + expect(onSelect).toHaveBeenCalledWith("/home/u/proj"); + }); + + it("Enter opens the selected folder and Backspace goes back up to it", async () => { + const dialog = await open(); + const list = within(dialog).getByRole("listbox"); + await userEvent.click(await option(dialog, "proj")); + await userEvent.keyboard("{Enter}"); + await option(dialog, "api"); + list.focus(); + await userEvent.keyboard("{Backspace}"); + await waitFor(() => + expect(within(dialog).getByRole("option", { name: /proj/ })).toHaveAttribute( + "aria-selected", + "true" + ) + ); + }); + + it("the breadcrumb, Back and Forward move between folders", async () => { + const dialog = await open(vi.fn(), "/home/u/proj/api"); + await userEvent.click(within(dialog).getByRole("button", { name: "proj" })); + await option(dialog, "web"); + await userEvent.click(within(dialog).getByRole("button", { name: /^Back/ })); + await waitFor(() => + expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/api", false, false) + ); + await userEvent.click(within(dialog).getByRole("button", { name: /^Forward/ })); + await waitFor(() => + expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj", false, false) + ); + // Above the user's own folder (it answered no parent) nothing is a link. + await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ })); + await option(dialog, "notes"); + expect(within(dialog).getByRole("button", { name: "home" })).toBeDisabled(); + }); + + it("the filter narrows the list, and Enter on a single match opens it", async () => { + const dialog = await open(); + await option(dialog, "notes"); + await userEvent.type(within(dialog).getByRole("textbox", { name: "Filter…" }), "pro"); + expect(within(dialog).queryByRole("option", { name: /notes/ })).toBeNull(); + await userEvent.keyboard("{Enter}"); + await option(dialog, "web"); + }); + + it("Places open a workspace and Home; Ctrl+L opens a typed path", async () => { + const dialog = await open(vi.fn(), "/home/u/proj"); + await userEvent.click(await within(dialog).findByRole("button", { name: /Shop API/ })); + await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith("/srv/ws", false, false)); + await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ })); + await option(dialog, "notes"); + await userEvent.keyboard("{Control>}l{/Control}"); + const field = within(dialog).getByRole("textbox", { name: "Type a path (Ctrl+L)" }); + await userEvent.clear(field); + await userEvent.type(field, "/home/u/proj/web{Enter}"); + await waitFor(() => + expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/web", false, false) + ); + }); + + it("New folder makes one in the current folder and selects it", async () => { + vi.mocked(api.lanes.mkdir).mockImplementation(async (parent, name) => { + TREE[parent] = [...(TREE[parent] ?? []), name]; + return { path: `${parent}/${name}` }; + }); + const dialog = await open(); + await option(dialog, "proj"); + await userEvent.click(within(dialog).getByRole("button", { name: /New folder/ })); + await userEvent.type( + within(dialog).getByRole("textbox", { name: "Folder name" }), + "fresh{Enter}" + ); + expect(api.lanes.mkdir).toHaveBeenCalledWith("/home/u", "fresh"); + await waitFor(async () => + expect(await option(dialog, "fresh")).toHaveAttribute("aria-selected", "true") + ); + TREE["/home/u"] = ["proj", "notes"]; + }); + + it("Show hidden re-lists with dot entries, and a pick is kept as Recent", async () => { + const onSelect = vi.fn(); + const dialog = await open(onSelect); + await option(dialog, "proj"); + expect(within(dialog).queryByRole("option", { name: /\.cfg/ })).toBeNull(); + await userEvent.click(within(dialog).getByRole("checkbox", { name: /Show hidden/ })); + await option(dialog, "\\.cfg"); + await userEvent.dblClick(await option(dialog, "proj")); + await option(dialog, "web"); + await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" })); + expect(onSelect).toHaveBeenCalledWith("/home/u/proj"); + expect(JSON.parse(localStorage.getItem("agenthub.browse.recent") || "[]")).toEqual([ + "/home/u/proj", + ]); + }); +}); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 4a5db7ed..134d3498 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -42,6 +42,24 @@ "browse.empty": "This folder is empty.", "browse.fileTitle": "Browse for a file", "browse.select": "Select this folder", + "browse.back": "Back (Alt+←)", + "browse.create": "Create", + "browse.editPath": "Type a path (Ctrl+L)", + "browse.filter": "Filter…", + "browse.forward": "Forward (Alt+→)", + "browse.hint": "Double-click or Enter opens · Backspace goes up · type to jump", + "browse.home": "Home", + "browse.modified": "Modified", + "browse.name": "Name", + "browse.newFolder": "New folder", + "browse.newFolderName": "Folder name", + "browse.noMatch": "Nothing matches the filter.", + "browse.places": "Places", + "browse.recent": "Recent", + "browse.selectFile": "Select file", + "browse.showHidden": "Show hidden", + "browse.up": "Up one level (Backspace)", + "browse.workspaces": "Workspaces", "browse.title": "Browse for a folder", "changes.back": "All files", "changes.bin": "bin", diff --git a/client/src/i18n/locales/en/users.json b/client/src/i18n/locales/en/users.json index 68d8bdc5..757f4f86 100644 --- a/client/src/i18n/locales/en/users.json +++ b/client/src/i18n/locales/en/users.json @@ -25,7 +25,7 @@ "username": "Username", "usernameHint": "Letters, digits, '.', '_' or '-'", "password": "Password", - "passwordHint": "At least 8 characters", + "passwordHint": "No minimum length", "newPassword": "New password", "changeRole": "Change role", "disable": "Disable", @@ -64,7 +64,7 @@ "ESELF": "You cannot change your own role or disable yourself.", "EEXISTS": "That username is taken.", "EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).", - "EBADPASSWORD": "Password: at least 8 characters." + "EBADPASSWORD": "Password cannot be empty." }, "readOnly": "Analysts can view accounts but not change them.", "auditTitle": "Audit log", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 26b3052a..2c1cdf17 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -42,6 +42,24 @@ "browse.empty": "Thư mục này trống.", "browse.fileTitle": "Duyệt chọn tệp", "browse.select": "Chọn thư mục này", + "browse.back": "Quay lại (Alt+←)", + "browse.create": "Tạo", + "browse.editPath": "Gõ đường dẫn (Ctrl+L)", + "browse.filter": "Lọc…", + "browse.forward": "Tiến (Alt+→)", + "browse.hint": "Nháy đúp hoặc Enter để mở · Backspace để lên · gõ chữ để nhảy tới", + "browse.home": "Thư mục chính", + "browse.modified": "Ngày sửa", + "browse.name": "Tên", + "browse.newFolder": "Thư mục mới", + "browse.newFolderName": "Tên thư mục", + "browse.noMatch": "Không có mục nào khớp bộ lọc.", + "browse.places": "Vị trí", + "browse.recent": "Gần đây", + "browse.selectFile": "Chọn tệp", + "browse.showHidden": "Hiện mục ẩn", + "browse.up": "Lên một cấp (Backspace)", + "browse.workspaces": "Workspace", "browse.title": "Duyệt chọn thư mục", "changes.back": "Tất cả file", "changes.bin": "bin", diff --git a/client/src/i18n/locales/vi/users.json b/client/src/i18n/locales/vi/users.json index 9014ff47..eb55ac08 100644 --- a/client/src/i18n/locales/vi/users.json +++ b/client/src/i18n/locales/vi/users.json @@ -25,7 +25,7 @@ "username": "Tên đăng nhập", "usernameHint": "Chữ, số, '.', '_' hoặc '-'", "password": "Mật khẩu", - "passwordHint": "Ít nhất 8 ký tự", + "passwordHint": "Không giới hạn độ dài tối thiểu", "newPassword": "Mật khẩu mới", "changeRole": "Đổi vai trò", "disable": "Khoá", @@ -64,7 +64,7 @@ "ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.", "EEXISTS": "Tên đăng nhập này đã có người dùng.", "EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).", - "EBADPASSWORD": "Mật khẩu: ít nhất 8 ký tự." + "EBADPASSWORD": "Mật khẩu không được để trống." }, "readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.", "auditTitle": "Nhật ký audit", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 6c8a908c..54bc80e5 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -2971,19 +2971,39 @@ export const api = { * filesystem paths from a native picker, so this drives a server-backed one. * @param path Absolute path to list; defaults server-side to the home dir. * @param files List files too (`file: true`) — a file field's picker. - * @returns `{ path, parent, entries }` — `parent` is null at the root. + * @param hidden List dot entries too (never at the top of a member's home). + * @returns `{ path, parent, entries, home }` — `parent` is null at the + * root, `home` is the folder the browser's Home place opens. */ - browse: (path?: string, files?: boolean) => { + browse: (path?: string, files?: boolean, hidden?: boolean) => { const q = new URLSearchParams(); if (path) q.set("path", path); if (files) q.set("files", "1"); + if (hidden) q.set("hidden", "1"); const qs = q.toString(); return request<{ path: string; parent: string | null; - entries: { name: string; path: string; isGitRepo: boolean; file?: boolean }[]; + home?: string; + entries: { + name: string; + path: string; + isGitRepo: boolean; + file?: boolean; + mtime?: number | null; + }[]; }>(`/lanes/browse${qs ? `?${qs}` : ""}`); }, + /** + * POST /api/lanes/browse/mkdir — the folder browser's New folder: one + * folder `name` inside `parent`. Throws `.code` `EBADNAME`, `EEXISTS`, + * `EFOLDER` (outside the caller's own folder). + */ + mkdir: (parent: string, name: string) => + request<{ path: string }>("/lanes/browse/mkdir", { + method: "POST", + body: JSON.stringify({ parent, name }), + }), /** * POST /api/lanes/pick-path — open the OS's own folder/file picker on the * server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a diff --git a/docs/API.md b/docs/API.md index 65d63f5a..49a3d8f7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -235,9 +235,17 @@ back on ANY of those to the in-app browser `GET /api/lanes/browse?path=`. A file field asks it with `files=1`: entries then include the folder's files (`file: true`, listed after the folders), and `path` opens the way the native picker's `start` does (`~` expanded, a file's folder, home when missing) — -a click on a file picks it. In server mode with -`AGENTHUB_USERS_ROOT` set, a developer's browser opens on their own folder -(`parent: null` there) and any path whose real path leaves it is `403 EFOLDER`. +a double-click on a file picks it. `hidden=1` lists dot entries too (never +those at the top of a non-admin's own folder), every entry carries its `mtime` +(ms) and the answer its `home` (the browser's Home place). In server mode with +`AGENTHUB_USERS_ROOT` set (under S3, else server.conf's `USERS_ROOT`), a +developer's browser opens on their own folder (`parent: null` there) and any +path whose real path leaves it is `403 EFOLDER`. The browser's **New folder** +is `POST /api/lanes/browse/mkdir` (`{parent, name}` → `201 {path}`): one plain +folder name inside an existing folder, `400 EBADNAME`/`ENOTFOUND`, +`409 EEXISTS`; a non-admin only inside their own folder, and in server mode +not at all without one (`403 EFOLDER`); a member's folder is made as the +member (S3). #### Update a workspace diff --git a/docs/LANES.md b/docs/LANES.md index 11be58a4..4e960002 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -89,7 +89,7 @@ than behind a disclosure — a disclosure around a single field only hid it. A workspace has no AI Proxy of its own: each NEW session picks one. The **AI Proxy** select in the workspace home's prompt box lists the profiles saved in Settings › AI Proxy (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it shows Native alone, disabled, when the session's AI tool cannot use a proxy (only Claude and Codex can), and it remembers the last pick (`agenthub.session.proxy`, falling back to the pre-rename `agenthub.session.router`). The start sends `proxy: ""` (`router` is still accepted); a Claude pane then runs with `--settings=/proxy-settings/.json`, which outranks the workspace's own settings, and a Codex pane with `-p ` plus its key through tmux `-e`. So two sessions of one workspace can run on two proxies, a worktree session gets its proxy too, and switching never touches a running session. A resume goes back to the proxy its session last ran on (`dashboard_runs.proxy`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's proxy into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Proxy profiles. -Both modes' path field has a **Browse** button next to it that opens the operating system's own folder picker (`POST /api/lanes/pick-path`). A browser cannot hand a web page an absolute path from its own dialog, so the SERVER opens the dialog on its desktop (zenity/kdialog on Linux, `osascript` on macOS, PowerShell on Windows) — which is your screen only when the browser runs on the same machine. A browser on another machine, or a server with no desktop or no zenity/kdialog, gets the small in-app folder browser instead (`GET /api/lanes/browse?path=`): click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field; git repos are marked. The same button sits beside the workspace home's **Folder** field, Import history's folder, the project-memory folder, CLAUDE_HOME/CODEX_HOME and a remote source's SSH identity file (a file picker: its in-app fallback lists the folder's files too, and a click on a file picks it). +Both modes' path field has a **Browse** button next to it that opens the operating system's own folder picker (`POST /api/lanes/pick-path`). A browser cannot hand a web page an absolute path from its own dialog, so the SERVER opens the dialog on its desktop (zenity/kdialog on Linux, `osascript` on macOS, PowerShell on Windows) — which is your screen only when the browser runs on the same machine. A browser on another machine, or a server with no desktop or no zenity/kdialog, gets the in-app folder browser instead (`GET /api/lanes/browse?path=`), laid out like a native picker: Back/Forward/Up, a clickable breadcrumb that becomes a path field (✎ or Ctrl+L), Places (Home, your workspaces, recent picks), a filter, Name/Modified columns (click to sort), **Show hidden**, and **New folder**. A click selects, a double-click or Enter opens (Backspace goes up, typing jumps to a name), and "Select this folder" fills the path field with the selected folder, else the one shown; git repos are marked. The same button sits beside the workspace home's **Folder** field, Import history's folder, the project-memory folder, CLAUDE_HOME/CODEX_HOME and a remote source's SSH identity file (a file picker: its in-app fallback lists the folder's files too, and a double-click on a file picks it). ## Workspace tool: Claude or Codex diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index a0475a83..bf5d6145 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -196,7 +196,9 @@ that already belongs to another user's workspace gets `409 EOWNED`. Admins are not limited. Without [one Linux user per account](#one-linux-user-per-account) you create each user's folder yourself, and this keeps workspaces apart, not agents. `install-system` sets this value to `/srv/agenthub/users`, and adding a -user makes the folder (it is the Linux account's home). +user makes the folder (it is the Linux account's home). With S3 installed, a +settings file that lacks the variable falls back to `USERS_ROOT` in +`/etc/agenthub/server.conf`, so a developer is never shown the host's home. ## What the gate does @@ -222,7 +224,7 @@ user makes the folder (it is the Linux account's home). - A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie `agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password change and disabling the user each end it. -- Passwords are stored as scrypt hashes and must be at least 8 characters. +- Passwords are stored as scrypt hashes; any non-empty password is accepted (no minimum length). Every login attempt counts before its password is checked, so attempts sent in parallel count too. After 10 failed logins from one address, or 30 for one username, in 15 minutes, further logins answer `429 ETOOMANY` until the @@ -539,7 +541,8 @@ that are not theirs. Each case below is refused, with a test in line AgentHub adds for a new worktree or `.mcp.json` is written only when the repo's real git dir is the same account's; a FIFO there cannot hang the dashboard. -- **Browsing and branch lists.** A developer's `GET /api/lanes/browse` and +- **Browsing and branch lists.** A developer's `GET /api/lanes/browse`, the + browser's New folder (`POST /api/lanes/browse/mkdir`, made as the member) and `GET /api/lanes/branches` stay inside their own folder (`403 EFOLDER`), and so does a workspace's extra folder (`POST /api/lanes/:id/sources/folder`, refused before the path is looked at, so the answer says nothing about diff --git a/server/__tests__/lane-lifecycle.test.js b/server/__tests__/lane-lifecycle.test.js index 7f7a94a2..14f066dd 100644 --- a/server/__tests__/lane-lifecycle.test.js +++ b/server/__tests__/lane-lifecycle.test.js @@ -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", () => { diff --git a/server/__tests__/scope-users-root.test.js b/server/__tests__/scope-users-root.test.js new file mode 100644 index 00000000..c984c44e --- /dev/null +++ b/server/__tests__/scope-users-root.test.js @@ -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ĩ + */ + +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); + }); +}); diff --git a/server/__tests__/server-mode-user-admin.test.js b/server/__tests__/server-mode-user-admin.test.js index 0b04fcbd..8cbc7d5f 100644 --- a/server/__tests__/server-mode-user-admin.test.js +++ b/server/__tests__/server-mode-user-admin.test.js @@ -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 () => { diff --git a/server/__tests__/server-mode-workspaces.test.js b/server/__tests__/server-mode-workspaces.test.js index 905fd4d0..bd56bcec 100644 --- a/server/__tests__/server-mode-workspaces.test.js +++ b/server/__tests__/server-mode-workspaces.test.js @@ -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( diff --git a/server/__tests__/server-s3-review2.test.js b/server/__tests__/server-s3-review2.test.js index 34686be3..e1309938 100644 --- a/server/__tests__/server-s3-review2.test.js +++ b/server/__tests__/server-s3-review2.test.js @@ -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", diff --git a/server/lib/auth.js b/server/lib/auth.js index 032d1aac..b7fa551d 100644 --- a/server/lib/auth.js +++ b/server/lib/auth.js @@ -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"); } diff --git a/server/lib/scope.js b/server/lib/scope.js index 61cecbb6..e30956e3 100644 --- a/server/lib/scope.js +++ b/server/lib/scope.js @@ -289,12 +289,18 @@ function audienceIncludes(audience, user) { /** * A non-admin's own folder, `/`, 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); diff --git a/server/openapi-extra/auth.js b/server/openapi-extra/auth.js index 0d9785ae..37af84a9 100644 --- a/server/openapi-extra/auth.js +++ b/server/openapi-extra/auth.js @@ -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, }, }, diff --git a/server/openapi-extra/lanes.js b/server/openapi-extra/lanes.js index bd76116d..aa758898 100644 --- a/server/openapi-extra/lanes.js +++ b/server/openapi-extra/lanes.js @@ -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"], diff --git a/server/routes/lanes.js b/server/routes/lanes.js index c412b7aa..597be745 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -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}`.