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
@@ -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 () => {