From ad0f28e881ee8e6c8182bb4257f31f2370046357 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Mon, 15 Jun 2026 10:25:29 -0400 Subject: [PATCH] feat(agent-server): add include_hidden to file browser endpoints The folder/file browser endpoints (/api/file/search_subdirs and /api/file/home) unconditionally skip dot-entries, so GUIs built on them cannot browse or select hidden directories (e.g. ~/.config). Add an optional include_hidden query param (default False, so existing behavior is unchanged) to both endpoints. When true, dot-directories are listed; files and symlinks are still skipped. Co-authored-by: openhands --- .../openhands/agent_server/file_router.py | 46 ++++++++++++------- tests/agent_server/test_file_router.py | 36 +++++++++++++++ 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/file_router.py b/openhands-agent-server/openhands/agent_server/file_router.py index f86d7b248e..b573b549bc 100644 --- a/openhands-agent-server/openhands/agent_server/file_router.py +++ b/openhands-agent-server/openhands/agent_server/file_router.py @@ -151,17 +151,20 @@ async def download_file_query( return await _download_file(path) -def _list_home_favorites(home: Path, limit: int = 50) -> list[FileBrowserEntry]: - """Top-level visible directories inside the user's home, alphabetised. - - Hidden entries (names starting with '.') and symlinks are skipped so the - list matches what ``search_subdirs`` returns for the same path. +def _list_home_favorites( + home: Path, limit: int = 50, include_hidden: bool = False +) -> list[FileBrowserEntry]: + """Top-level directories inside the user's home, alphabetised. + + Symlinks are skipped. Hidden entries (names starting with '.') are skipped + unless ``include_hidden`` is True, so the list matches what + ``search_subdirs`` returns for the same path and the same flag. """ entries: list[FileBrowserEntry] = [] try: with os.scandir(home) as scanner: for entry in scanner: - if entry.name.startswith("."): + if not include_hidden and entry.name.startswith("."): continue try: if not entry.is_dir(follow_symlinks=False): @@ -197,18 +200,24 @@ def _list_root_locations() -> list[FileBrowserEntry]: @file_router.get("/home") -async def get_home_directory() -> HomeResponse: +async def get_home_directory( + include_hidden: Annotated[ + bool, + Query(description="Include hidden top-level directories in `favorites`"), + ] = False, +) -> HomeResponse: """Return the agent-server user's home directory and dynamic sidebar lists. - ``favorites`` is the set of visible top-level directories actually present - in the user's home (so it reflects the real environment instead of a - hardcoded list of names that may not exist). ``locations`` is the set of - filesystem roots — '/' on POSIX or available drive letters on Windows. + ``favorites`` is the set of top-level directories actually present in the + user's home (so it reflects the real environment instead of a hardcoded + list of names that may not exist). Hidden directories are included only + when ``include_hidden`` is True. ``locations`` is the set of filesystem + roots — '/' on POSIX or available drive letters on Windows. """ home = Path.home() return HomeResponse( home=str(home), - favorites=_list_home_favorites(home), + favorites=_list_home_favorites(home, include_hidden=include_hidden), locations=_list_root_locations(), ) @@ -227,12 +236,17 @@ async def search_subdirs( int, Query(title="The max number of results in the page", gt=0, lte=100), ] = 100, + include_hidden: Annotated[ + bool, + Query(title="Include hidden subdirectories (names starting with '.')"), + ] = False, ) -> SubdirectoryPage: """Search / List immediate subdirectories of `path`. - Used by the GUI's workspace picker. Hidden entries (names starting with '.') - and symlinks are skipped. Files are skipped. Returns absolute paths so the - GUI can use a result directly as ``workspace.working_dir``. + Used by the GUI's workspace picker. Symlinks and files are skipped. Hidden + entries (names starting with '.') are skipped unless ``include_hidden`` is + True. Returns absolute paths so the GUI can use a result directly as + ``workspace.working_dir``. Results are sorted case-insensitively by name and paginated. ``page_id`` is the ``next_page_id`` returned by the previous page (the lowercase name of @@ -262,7 +276,7 @@ async def search_subdirs( try: with os.scandir(target) as scanner: for entry in scanner: - if entry.name.startswith("."): + if not include_hidden and entry.name.startswith("."): continue try: if not entry.is_dir(follow_symlinks=False): diff --git a/tests/agent_server/test_file_router.py b/tests/agent_server/test_file_router.py index bd80ac23a8..dcf0864412 100644 --- a/tests/agent_server/test_file_router.py +++ b/tests/agent_server/test_file_router.py @@ -319,6 +319,24 @@ def test_search_subdirs_returns_only_directories_with_absolute_paths(client, tmp assert body["next_page_id"] is None +def test_search_subdirs_include_hidden_lists_dot_directories(client, tmp_path): + """With include_hidden=true, dot-directories are listed (files still skipped).""" + (tmp_path / "repo1").mkdir() + (tmp_path / ".hidden_dir").mkdir() + (tmp_path / "README.md").write_text("hi") + + response = client.get( + "/api/file/search_subdirs", + params={"path": str(tmp_path), "include_hidden": "true"}, + ) + + assert response.status_code == 200 + body = response.json() + names = [entry["name"] for entry in body["items"]] + # Sorted case-insensitively; '.' sorts before alphanumerics. + assert names == [".hidden_dir", "repo1"] + + def test_search_subdirs_relative_path_returns_400(client): response = client.get("/api/file/search_subdirs", params={"path": "relative/path"}) assert response.status_code == 400 @@ -422,6 +440,24 @@ def test_get_home_returns_dynamic_favorites_and_locations( assert body["locations"] == [{"label": "/", "path": "/"}] +def test_get_home_include_hidden_lists_hidden_favorites(client, tmp_path, monkeypatch): + # With include_hidden=true, hidden top-level directories appear in favorites + # (files are still excluded). + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "projects").mkdir() + (tmp_path / ".cache").mkdir() + (tmp_path / "readme.txt").write_text("ignored") + + response = client.get("/api/file/home", params={"include_hidden": "true"}) + + assert response.status_code == 200 + body = response.json() + assert body["favorites"] == [ + {"label": ".cache", "path": str(tmp_path / ".cache")}, + {"label": "projects", "path": str(tmp_path / "projects")}, + ] + + @pytest.mark.timeout(20) async def test_upload_does_not_block_event_loop_on_slow_storage(tmp_path, monkeypatch): # Drive _upload_file directly, not via ASGI: in-process ASGI interleaves