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
@@ -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ĩ <vinnt@smartgift.vn>
*/
@@ -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(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
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();
+593 -53
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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<ReturnType<typeof api.lanes.browse>>;
type Entry = Listing["entries"][number];
const RECENT_KEY = "agenthub.browse.recent";
const HIDDEN_KEY = "agenthub.browse.hidden";
const RECENT_MAX = 8;
function readStored<T>(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 ? <BrowseDialog {...props} /> : null;
}
function BrowseDialog({
open,
kind = "dir",
initialPath,
@@ -30,20 +107,62 @@ export function FolderBrowseModal({
onClose: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
const { lanes } = useWorkLanes();
const [listing, setListing] = useState<Listing | null>(null);
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 });
const [selected, setSelected] = useState<string | null>(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<string[]>(() => 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<string | null>(null);
const [home, setHome] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const crumbRef = useRef<HTMLDivElement>(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<HTMLDivElement>(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<string>();
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<HTMLElement>("[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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@@ -64,62 +339,330 @@ export function FolderBrowseModal({
>
<div
ref={panel}
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
className="relative flex h-[min(80vh,640px)] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
onKeyDown={onPanelKey}
role="dialog"
aria-modal="true"
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
data-tour="folder-browse"
>
<div className="border-b border-border p-3">
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
{listing?.path || "…"}
{/* Toolbar: history, up, and the breadcrumb / path field. */}
<div className="flex items-center gap-1 border-b border-border p-2">
<button
type="button"
className={iconBtn}
onClick={() => go(-1)}
disabled={history.at <= 0}
title={t("browse.back")}
aria-label={t("browse.back")}
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
className={iconBtn}
onClick={() => go(1)}
disabled={history.at >= history.stack.length - 1}
title={t("browse.forward")}
aria-label={t("browse.forward")}
>
<ArrowRight className="h-4 w-4" />
</button>
<button
type="button"
className={iconBtn}
onClick={goUp}
disabled={!listing?.parent}
title={t("browse.up")}
aria-label={t("browse.up")}
>
<ArrowUp className="h-4 w-4" />
</button>
{editing ? (
<input
autoFocus
value={pathText}
onChange={(e) => 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"
/>
) : (
<div
ref={crumbRef}
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md border border-border-light px-1 py-0.5"
onDoubleClick={() => {
setPathText(path);
setEditing(true);
}}
>
{crumbs(path).map(([label, p], i, all) => {
const last = i === all.length - 1;
const locked = i + 1 < floorDepth;
return (
<span key={p} className="flex shrink-0 items-center">
{i > 0 && all[i - 1]?.[0] !== "/" && (
<span className="px-0.5 text-fg-muted">›</span>
)}
<button
type="button"
disabled={last || locked}
onClick={() => void load(p, { select: all[i + 1]?.[1] ?? null })}
className={`rounded px-1 py-0.5 font-mono text-xs ${
last
? "font-medium text-fg-primary"
: locked
? "text-fg-muted"
: "text-fg-secondary hover:bg-surface-2"
}`}
>
{label}
</button>
</span>
);
})}
</div>
)}
<button
type="button"
className={iconBtn}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setPathText(path);
setEditing((v) => !v);
}}
title={t("browse.editPath")}
aria-label={t("browse.editPath")}
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-1">
<div className="flex min-h-0 flex-1">
{/* Places */}
<nav
className="hidden w-44 shrink-0 overflow-y-auto border-r border-border p-2 sm:block"
aria-label={t("browse.places")}
>
{home && (
<button
type="button"
className={placeBtn(path === home)}
onClick={() => void load(home)}
title={home}
>
<Home className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{t("browse.home")}</span>
</button>
)}
{places.length > 0 && (
<>
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
<Star className="h-3 w-3" />
{t("browse.workspaces")}
</div>
{places.map((pl) => (
<button
key={pl.path}
type="button"
className={placeBtn(path === pl.path)}
onClick={() => void load(pl.path)}
title={pl.path}
>
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">{pl.label}</span>
</button>
))}
</>
)}
{recent.length > 0 && (
<>
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
<Clock className="h-3 w-3" />
{t("browse.recent")}
</div>
{recent.map((r) => (
<button
key={r}
type="button"
className={placeBtn(path === r)}
onClick={() => void load(r)}
title={r}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{baseName(r)}</span>
</button>
))}
</>
)}
</nav>
{/* Listing */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border p-2">
<input
value={filter}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center border-b border-border px-3 py-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
{(["name", "mtime"] as const).map((key) => (
<button
key={key}
type="button"
onClick={() =>
setSort((s) => ({ key, dir: s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }))
}
className={`${key === "name" ? "flex-1 text-left" : "hidden w-32 text-right sm:block"} hover:text-fg-secondary`}
>
{key === "name" ? t("browse.name") : t("browse.modified")}
{sort.key === key && (sort.dir === 1 ? " ↑" : " ↓")}
</button>
))}
</div>
<div
ref={listRef}
tabIndex={0}
role="listbox"
aria-label={path}
onKeyDown={onListKey}
className="min-h-0 flex-1 overflow-y-auto p-1 outline-none"
>
{error && (
<p role="alert" className="p-2 text-xs text-status-danger">
{error}
</p>
)}
{listing?.parent && (
<button
type="button"
onClick={() => load(listing.parent!)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
>
<ArrowUp className="h-3.5 w-3.5" />
..
</button>
)}
{listing?.entries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => {
if (!entry.file) return void load(entry.path);
onSelect(entry.path);
onClose();
{creating && (
<div className="flex items-center gap-2 px-2 py-1">
<FolderPlus className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (isSubmitEnter(e)) {
e.preventDefault();
void createFolder();
} else if (e.key === "Escape") {
e.preventDefault();
setCreating(false);
}
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
placeholder={t("browse.newFolderName")}
aria-label={t("browse.newFolderName")}
className="input min-w-0 flex-1 text-xs"
/>
<button
type="button"
onClick={() => void createFolder()}
disabled={!newName.trim()}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.create")}
</button>
</div>
)}
{entries.map((entry) => {
const isSel = entry.path === selected;
return (
<div
key={entry.path}
data-path={entry.path}
role="option"
aria-selected={isSel}
onClick={() => 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 ? (
<File className="h-3.5 w-3.5 text-fg-muted" />
<File className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`} />
) : entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
<FolderGit2
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-accent"}`}
/>
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
<FolderOpen
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`}
/>
)}
<span className="truncate">{entry.name}</span>
</button>
))}
{listing && listing.entries.length === 0 && !listing.parent && (
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
<span
className={`hidden w-32 shrink-0 text-right tabular-nums sm:block ${isSel ? "" : "text-fg-muted"}`}
>
{entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""}
</span>
</div>
);
})}
{listing && entries.length === 0 && (
<p className="p-2 text-xs text-fg-muted">
{filter.trim() ? t("browse.noMatch") : t("browse.empty")}
</p>
)}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
{/* Footer: what will be picked, and the actions. */}
<div className="border-t border-border p-2">
<div
// rtl so a long path loses its START to the ellipsis, not the folder.
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
title={target ?? undefined}
>
<bdi>{target ?? "…"}</bdi>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
setNewName("");
setCreating(true);
}}
disabled={!listing}
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
>
<FolderPlus className="h-3.5 w-3.5" />
{t("browse.newFolder")}
</button>
<label className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary">
<input type="checkbox" checked={showHidden} onChange={toggleHidden} />
<Eye className="h-3.5 w-3.5" />
{t("browse.showHidden")}
</label>
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
{t("browse.hint")}
</span>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={onClose}
@@ -127,19 +670,16 @@ export function FolderBrowseModal({
>
{t("destructive.cancel")}
</button>
{kind === "dir" && (
<button
type="button"
disabled={!listing}
onClick={() => {
if (listing) onSelect(listing.path);
onClose();
}}
disabled={!target}
onClick={() => target && finish(target)}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.select")}
{kind === "file" ? t("browse.selectFile") : t("browse.select")}
</button>
)}
</div>
</div>
</div>
</div>
</div>
@@ -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 () => {
@@ -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ĩ <vinnt@smartgift.vn>
*/
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<string, string[]> = {
"/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(
<FolderBrowseModal open initialPath={initialPath} onSelect={onSelect} onClose={vi.fn()} />
);
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",
]);
});
});
+18
View File
@@ -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",
+2 -2
View File
@@ -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",
+18
View File
@@ -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",
+2 -2
View File
@@ -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",
+23 -3
View File
@@ -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
+11 -3
View File
@@ -235,9 +235,17 @@ back on ANY of those to the in-app browser `GET /api/lanes/browse?path=<abs>`.
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
+1 -1
View File
@@ -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: "<name>"` (`router` is still accepted); a Claude pane then runs with `--settings=<data dir>/proxy-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` 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=<abs>`): 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=<abs>`), 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
+6 -3
View File
@@ -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
+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",
+3 -3
View File
@@ -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
View File
@@ -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);
+2 -2
View File
@@ -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,
},
},
+36
View File
@@ -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
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}`.