From 083952b6cabd42a80e233aed0df049c36653c18c Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 15 Jun 2026 10:33:52 -0400 Subject: [PATCH 1/3] feat(workspace): add 'Show hidden folders' toggle to the folder browser The folder browser cannot show hidden (dot) directories because the agent-server filters them out server-side. Pair the new agent-server 'include_hidden' query param with a 'Show hidden folders' checkbox in the folder browser: when enabled, the sidebar favorites and the main listing request hidden directories so users can browse into and add a hidden directory (e.g. ~/.config) as a workspace. useSearchSubdirs/useHomeDirectory gain an includeHidden arg. The pinned typed FileClient cannot forward include_hidden yet, so the hidden path goes through the typed HttpClient as a temporary bridge; older agent-servers ignore the unknown param and keep filtering, so this is backward-compatible. Co-authored-by: openhands --- .../hooks/query/use-search-subdirs.test.tsx | 120 ++++++++++++++++++ .../folder-browser-modal.tsx | 17 ++- src/hooks/query/use-search-subdirs.ts | 78 +++++++++++- src/i18n/translation.json | 17 +++ 4 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 __tests__/hooks/query/use-search-subdirs.test.tsx diff --git a/__tests__/hooks/query/use-search-subdirs.test.tsx b/__tests__/hooks/query/use-search-subdirs.test.tsx new file mode 100644 index 000000000..67fdc36cf --- /dev/null +++ b/__tests__/hooks/query/use-search-subdirs.test.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + useSearchSubdirs, + useHomeDirectory, +} from "#/hooks/query/use-search-subdirs"; + +const backendMock = vi.hoisted(() => ({ + current: { + backend: { id: "local-1", kind: "local" as "local" | "cloud" }, + orgId: null as string | null, + }, +})); +vi.mock("#/contexts/active-backend-context", () => ({ + useActiveBackend: () => backendMock.current, +})); + +vi.mock("#/api/agent-server-client-options", () => ({ + getAgentServerClientOptions: () => ({ host: "http://localhost" }), + getAgentServerHttpClientOptions: () => ({ baseUrl: "http://localhost" }), +})); + +const fileSearch = vi.hoisted(() => vi.fn()); +const fileGetHome = vi.hoisted(() => vi.fn()); +vi.mock("@openhands/typescript-client/clients", () => ({ + FileClient: class { + searchSubdirectories = fileSearch; + + getHome = fileGetHome; + }, +})); + +const httpGet = vi.hoisted(() => vi.fn()); +vi.mock("@openhands/typescript-client/client/http-client", () => ({ + HttpClient: class { + get = httpGet; + }, +})); + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +} + +beforeEach(() => { + vi.clearAllMocks(); + backendMock.current = { + backend: { id: "local-1", kind: "local" }, + orgId: null, + }; +}); + +describe("useSearchSubdirs", () => { + it("uses the typed FileClient and omits hidden dirs by default", async () => { + fileSearch.mockResolvedValue({ items: [], next_page_id: null }); + + const { result } = renderHook(() => useSearchSubdirs("/home/me"), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fileSearch).toHaveBeenCalledWith("/home/me"); + expect(httpGet).not.toHaveBeenCalled(); + }); + + it("requests include_hidden via HttpClient when showing hidden dirs", async () => { + httpGet.mockResolvedValue({ + data: { + items: [{ name: ".config", path: "/home/me/.config" }], + next_page_id: null, + }, + }); + + const { result } = renderHook(() => useSearchSubdirs("/home/me", true), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(httpGet).toHaveBeenCalledWith("/api/file/search_subdirs", { + params: { path: "/home/me", include_hidden: true }, + }); + expect(fileSearch).not.toHaveBeenCalled(); + expect(result.current.data?.items[0]?.name).toBe(".config"); + }); +}); + +describe("useHomeDirectory", () => { + it("uses the typed FileClient by default", async () => { + fileGetHome.mockResolvedValue({ home: "/home/me" }); + + const { result } = renderHook(() => useHomeDirectory(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fileGetHome).toHaveBeenCalledTimes(1); + expect(httpGet).not.toHaveBeenCalled(); + }); + + it("requests include_hidden via HttpClient when showing hidden dirs", async () => { + httpGet.mockResolvedValue({ + data: { + home: "/home/me", + favorites: [{ label: ".cache", path: "/home/me/.cache" }], + }, + }); + + const { result } = renderHook(() => useHomeDirectory(true), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(httpGet).toHaveBeenCalledWith("/api/file/home", { + params: { include_hidden: true }, + }); + expect(fileGetHome).not.toHaveBeenCalled(); + expect(result.current.data?.favorites?.[0]?.label).toBe(".cache"); + }); +}); diff --git a/src/components/features/home/workspace-dropdown/folder-browser-modal.tsx b/src/components/features/home/workspace-dropdown/folder-browser-modal.tsx index 0c0933c6e..d9fc61248 100644 --- a/src/components/features/home/workspace-dropdown/folder-browser-modal.tsx +++ b/src/components/features/home/workspace-dropdown/folder-browser-modal.tsx @@ -124,9 +124,12 @@ export function FolderBrowserModal({ }: FolderBrowserModalProps) { const { t } = useTranslation("openhands"); const [currentPath, setCurrentPath] = useState(null); + // When enabled, hidden (dot) directories are requested from the agent-server + // so they show up in both the sidebar favorites and the main listing. + const [showHidden, setShowHidden] = useState(false); const active = useActiveBackend(); - const { data: homeData } = useHomeDirectory(); + const { data: homeData } = useHomeDirectory(showHidden); // Initialize / reset to home each time the modal is opened useEffect(() => { @@ -151,7 +154,7 @@ export function FolderBrowserModal({ isLoading, isError, error, - } = useSearchSubdirs(isOpen ? currentPath : null); + } = useSearchSubdirs(isOpen ? currentPath : null, showHidden); const favorites: SidebarEntry[] = useMemo(() => { if (!homeData?.home) return []; @@ -287,6 +290,16 @@ export function FolderBrowserModal({ > {currentPath ?? ""} + {/* Column headers */} diff --git a/src/hooks/query/use-search-subdirs.ts b/src/hooks/query/use-search-subdirs.ts index 6fe5c1148..3491a7e2a 100644 --- a/src/hooks/query/use-search-subdirs.ts +++ b/src/hooks/query/use-search-subdirs.ts @@ -1,6 +1,16 @@ import { useQuery } from "@tanstack/react-query"; import { FileClient } from "@openhands/typescript-client/clients"; -import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +// Temporary bridge: the pinned typed `FileClient` cannot forward an +// `include_hidden` query param yet, so the hidden-folder path uses the typed +// `HttpClient` directly. Remove this once a released client adds the param to +// `FileClient.searchSubdirectories` / `getHome` (see agent-server PR adding +// `include_hidden` to /api/file/search_subdirs + /api/file/home). +// eslint-disable-next-line no-restricted-imports +import { HttpClient } from "@openhands/typescript-client/client/http-client"; +import { + getAgentServerClientOptions, + getAgentServerHttpClientOptions, +} from "#/api/agent-server-client-options"; import { useActiveBackend } from "#/contexts/active-backend-context"; export interface FileBrowserEntry { @@ -14,27 +24,81 @@ export interface HomeDirectoryResponse { locations?: FileBrowserEntry[]; } +interface SubdirectoryEntry { + name: string; + path: string; +} + +interface SubdirectoryPage { + items: SubdirectoryEntry[]; + next_page_id: string | null; +} + +const SEARCH_SUBDIRS_PATH = "/api/file/search_subdirs"; +const HOME_PATH = "/api/file/home"; + function getFileClient() { return new FileClient(getAgentServerClientOptions()); } -export const useSearchSubdirs = (path: string | null) => { +// The pinned typed `FileClient` does not yet forward an `include_hidden` +// query param, so the hidden-folder path goes through the (also typed and +// guard-approved) `HttpClient`. Older agent-servers simply ignore the unknown +// param and keep filtering hidden entries, so this stays backward-compatible. +function searchSubdirectories( + path: string, + includeHidden: boolean, +): Promise { + if (!includeHidden) { + return getFileClient().searchSubdirectories(path); + } + return new HttpClient(getAgentServerHttpClientOptions()) + .get(SEARCH_SUBDIRS_PATH, { + params: { path, include_hidden: true }, + }) + .then((response) => response.data); +} + +function getHomeDirectory( + includeHidden: boolean, +): Promise { + if (!includeHidden) { + return getFileClient().getHome(); + } + return new HttpClient(getAgentServerHttpClientOptions()) + .get(HOME_PATH, { + params: { include_hidden: true }, + }) + .then((response) => response.data); +} + +export const useSearchSubdirs = ( + path: string | null, + includeHidden = false, +) => { const active = useActiveBackend(); return useQuery({ - queryKey: ["file", "search_subdirs", path, active.backend.id, active.orgId], - queryFn: () => getFileClient().searchSubdirectories(path as string), + queryKey: [ + "file", + "search_subdirs", + path, + includeHidden, + active.backend.id, + active.orgId, + ], + queryFn: () => searchSubdirectories(path as string, includeHidden), enabled: !!path, retry: false, meta: { disableToast: true }, }); }; -export const useHomeDirectory = () => { +export const useHomeDirectory = (includeHidden = false) => { const active = useActiveBackend(); return useQuery({ - queryKey: ["file", "home", active.backend.id, active.orgId], + queryKey: ["file", "home", includeHidden, active.backend.id, active.orgId], queryFn: async (): Promise => - getFileClient().getHome(), + getHomeDirectory(includeHidden), retry: false, meta: { disableToast: true }, staleTime: Infinity, diff --git a/src/i18n/translation.json b/src/i18n/translation.json index e4b11991a..3afe46608 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -20076,6 +20076,23 @@ "uk": "Додати робочі області", "ca": "Afegeix espais de treball" }, + "HOME$SHOW_HIDDEN_FOLDERS": { + "en": "Show hidden folders", + "ja": "隠しフォルダを表示", + "zh-CN": "显示隐藏文件夹", + "zh-TW": "顯示隱藏資料夾", + "ko-KR": "숨김 폴더 표시", + "no": "Vis skjulte mapper", + "it": "Mostra cartelle nascoste", + "pt": "Mostrar pastas ocultas", + "es": "Mostrar carpetas ocultas", + "ar": "إظهار المجلدات المخفية", + "fr": "Afficher les dossiers cachés", + "tr": "Gizli klasörleri göster", + "de": "Versteckte Ordner anzeigen", + "uk": "Показати приховані теки", + "ca": "Mostra les carpetes ocultes" + }, "HOME$ADD_THIS_DIRECTORY": { "en": "Add this directory", "ja": "このディレクトリを追加", From 0b6122a2aa9104edab2436a6462c07242b20b3a3 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 19 Jun 2026 16:12:32 -0400 Subject: [PATCH 2/3] fix(workspace): make hidden-folder path compliant with API-access guard The previous implementation routed the include_hidden requests through the low-level SDK HttpClient, which the no-direct-agent-server-calls guard bans unconditionally (imports and construction). Replace that bridge with a dedicated, allowlisted axios module (src/api/file-browser/file-browser-api.ts) that mirrors the automation-service pattern: backend-registry session-key resolution for local backends and callCloudProxy for cloud backends. The non-hidden path keeps using the typed FileClient; only the hidden variant issues axios calls. This is a temporary exception on the guard's allowlist: once @openhands/typescript-client adds includeHidden to FileClient, bump the pin, route both calls through the typed client, and delete this module plus its allowlist entry. Also rebases onto current main. Co-authored-by: openhands --- .../hooks/query/use-search-subdirs.test.tsx | 43 ++++---- src/api/file-browser/file-browser-api.ts | 101 ++++++++++++++++++ src/api/no-direct-agent-server-calls.test.ts | 5 + src/hooks/query/use-search-subdirs.ts | 68 ++---------- 4 files changed, 135 insertions(+), 82 deletions(-) create mode 100644 src/api/file-browser/file-browser-api.ts diff --git a/__tests__/hooks/query/use-search-subdirs.test.tsx b/__tests__/hooks/query/use-search-subdirs.test.tsx index 67fdc36cf..ca7b1f31b 100644 --- a/__tests__/hooks/query/use-search-subdirs.test.tsx +++ b/__tests__/hooks/query/use-search-subdirs.test.tsx @@ -20,7 +20,6 @@ vi.mock("#/contexts/active-backend-context", () => ({ vi.mock("#/api/agent-server-client-options", () => ({ getAgentServerClientOptions: () => ({ host: "http://localhost" }), - getAgentServerHttpClientOptions: () => ({ baseUrl: "http://localhost" }), })); const fileSearch = vi.hoisted(() => vi.fn()); @@ -33,10 +32,12 @@ vi.mock("@openhands/typescript-client/clients", () => ({ }, })); -const httpGet = vi.hoisted(() => vi.fn()); -vi.mock("@openhands/typescript-client/client/http-client", () => ({ - HttpClient: class { - get = httpGet; +const searchHidden = vi.hoisted(() => vi.fn()); +const getHomeHidden = vi.hoisted(() => vi.fn()); +vi.mock("#/api/file-browser/file-browser-api", () => ({ + fileBrowserApi: { + searchSubdirectoriesWithHidden: searchHidden, + getHomeWithHidden: getHomeHidden, }, })); @@ -65,15 +66,13 @@ describe("useSearchSubdirs", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(fileSearch).toHaveBeenCalledWith("/home/me"); - expect(httpGet).not.toHaveBeenCalled(); + expect(searchHidden).not.toHaveBeenCalled(); }); - it("requests include_hidden via HttpClient when showing hidden dirs", async () => { - httpGet.mockResolvedValue({ - data: { - items: [{ name: ".config", path: "/home/me/.config" }], - next_page_id: null, - }, + it("requests include_hidden when showing hidden dirs", async () => { + searchHidden.mockResolvedValue({ + items: [{ name: ".config", path: "/home/me/.config" }], + next_page_id: null, }); const { result } = renderHook(() => useSearchSubdirs("/home/me", true), { @@ -81,9 +80,7 @@ describe("useSearchSubdirs", () => { }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(httpGet).toHaveBeenCalledWith("/api/file/search_subdirs", { - params: { path: "/home/me", include_hidden: true }, - }); + expect(searchHidden).toHaveBeenCalledWith({ path: "/home/me" }); expect(fileSearch).not.toHaveBeenCalled(); expect(result.current.data?.items[0]?.name).toBe(".config"); }); @@ -97,23 +94,19 @@ describe("useHomeDirectory", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(fileGetHome).toHaveBeenCalledTimes(1); - expect(httpGet).not.toHaveBeenCalled(); + expect(getHomeHidden).not.toHaveBeenCalled(); }); - it("requests include_hidden via HttpClient when showing hidden dirs", async () => { - httpGet.mockResolvedValue({ - data: { - home: "/home/me", - favorites: [{ label: ".cache", path: "/home/me/.cache" }], - }, + it("requests include_hidden when showing hidden dirs", async () => { + getHomeHidden.mockResolvedValue({ + home: "/home/me", + favorites: [{ label: ".cache", path: "/home/me/.cache" }], }); const { result } = renderHook(() => useHomeDirectory(true), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(httpGet).toHaveBeenCalledWith("/api/file/home", { - params: { include_hidden: true }, - }); + expect(getHomeHidden).toHaveBeenCalledTimes(1); expect(fileGetHome).not.toHaveBeenCalled(); expect(result.current.data?.favorites?.[0]?.label).toBe(".cache"); }); diff --git a/src/api/file-browser/file-browser-api.ts b/src/api/file-browser/file-browser-api.ts new file mode 100644 index 000000000..207d61854 --- /dev/null +++ b/src/api/file-browser/file-browser-api.ts @@ -0,0 +1,101 @@ +import axios from "axios"; +import { + getActiveBackend, + getEffectiveLocalBackend, +} from "../backend-registry/active-store"; +import { callCloudProxy } from "../cloud/proxy"; +import { NoBackendAvailableError } from "../agent-server-client-options"; +import type { + FileBrowserEntry, + HomeDirectoryResponse, +} from "#/hooks/query/use-search-subdirs"; + +// TEMPORARY: the pinned typed `FileClient` (1.24.3) cannot forward an +// `include_hidden` query param to the agent-server file-browser endpoints, so +// the "show hidden folders" path issues those two requests directly via axios. +// This file is on the API-access guard's allowlist for exactly that reason. +// Once @openhands/typescript-client adds `includeHidden` to +// `FileClient.searchSubdirectories` / `getHome`, bump the client pin, route +// both calls through the typed client, and delete this module + its allowlist +// entry. + +interface SubdirectoryEntry { + name: string; + path: string; +} + +interface SubdirectoryPage { + items: SubdirectoryEntry[]; + next_page_id: string | null; +} + +const SEARCH_SUBDIRS_PATH = "/api/file/search_subdirs"; +const HOME_PATH = "/api/file/home"; + +const localFileAxios = axios.create(); + +localFileAxios.interceptors.request.use((config) => { + const backend = getEffectiveLocalBackend(); + if (!backend) throw new NoBackendAvailableError(); + // eslint-disable-next-line no-param-reassign + if (!config.baseURL) config.baseURL = backend.host; + const apiKey = backend.apiKey?.trim(); + if (apiKey) { + config.headers.set("X-Session-API-Key", apiKey); + } + return config; +}); + +interface HiddenSearchParams { + path: string; + pageId?: string | null; + limit?: number; +} + +async function searchSubdirectoriesWithHidden( + params: HiddenSearchParams, +): Promise { + const active = getActiveBackend(); + if (active.backend.kind === "cloud") { + const query = new URLSearchParams({ + path: params.path, + include_hidden: "true", + }); + if (params.pageId) query.set("page_id", params.pageId); + if (params.limit !== undefined) query.set("limit", String(params.limit)); + return callCloudProxy({ + backend: active.backend, + method: "GET", + path: `${SEARCH_SUBDIRS_PATH}?${query.toString()}`, + }); + } + + const response = await localFileAxios.get( + SEARCH_SUBDIRS_PATH, + { params: { ...params, include_hidden: true } }, + ); + return response.data; +} + +async function getHomeWithHidden(): Promise { + const active = getActiveBackend(); + if (active.backend.kind === "cloud") { + return callCloudProxy({ + backend: active.backend, + method: "GET", + path: `${HOME_PATH}?include_hidden=true`, + }); + } + + const response = await localFileAxios.get(HOME_PATH, { + params: { include_hidden: true }, + }); + return response.data; +} + +export const fileBrowserApi = { + searchSubdirectoriesWithHidden, + getHomeWithHidden, +}; + +export type { FileBrowserEntry, HomeDirectoryResponse }; diff --git a/src/api/no-direct-agent-server-calls.test.ts b/src/api/no-direct-agent-server-calls.test.ts index d640e898f..c4c6b313a 100644 --- a/src/api/no-direct-agent-server-calls.test.ts +++ b/src/api/no-direct-agent-server-calls.test.ts @@ -7,6 +7,11 @@ const EXCLUDED_SEGMENTS = new Set(["mocks", "routeTree.gen.ts"]); const ALLOWED_AD_HOC_HTTP_FILES = new Set([ "api/automation-service/automation-service.api.ts", "api/cloud/proxy.ts", + // TEMPORARY: forwards the agent-server's `include_hidden` query param for the + // folder browser's "Show hidden folders" toggle. The pinned typed FileClient + // cannot send this param yet; remove this entry once the client adds + // `includeHidden` and the call routes back through FileClient. + "api/file-browser/file-browser-api.ts", ]); function collectSourceFiles(dir: string): string[] { diff --git a/src/hooks/query/use-search-subdirs.ts b/src/hooks/query/use-search-subdirs.ts index 3491a7e2a..898d0432a 100644 --- a/src/hooks/query/use-search-subdirs.ts +++ b/src/hooks/query/use-search-subdirs.ts @@ -1,16 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { FileClient } from "@openhands/typescript-client/clients"; -// Temporary bridge: the pinned typed `FileClient` cannot forward an -// `include_hidden` query param yet, so the hidden-folder path uses the typed -// `HttpClient` directly. Remove this once a released client adds the param to -// `FileClient.searchSubdirectories` / `getHome` (see agent-server PR adding -// `include_hidden` to /api/file/search_subdirs + /api/file/home). -// eslint-disable-next-line no-restricted-imports -import { HttpClient } from "@openhands/typescript-client/client/http-client"; -import { - getAgentServerClientOptions, - getAgentServerHttpClientOptions, -} from "#/api/agent-server-client-options"; +import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { fileBrowserApi } from "#/api/file-browser/file-browser-api"; import { useActiveBackend } from "#/contexts/active-backend-context"; export interface FileBrowserEntry { @@ -24,54 +15,10 @@ export interface HomeDirectoryResponse { locations?: FileBrowserEntry[]; } -interface SubdirectoryEntry { - name: string; - path: string; -} - -interface SubdirectoryPage { - items: SubdirectoryEntry[]; - next_page_id: string | null; -} - -const SEARCH_SUBDIRS_PATH = "/api/file/search_subdirs"; -const HOME_PATH = "/api/file/home"; - function getFileClient() { return new FileClient(getAgentServerClientOptions()); } -// The pinned typed `FileClient` does not yet forward an `include_hidden` -// query param, so the hidden-folder path goes through the (also typed and -// guard-approved) `HttpClient`. Older agent-servers simply ignore the unknown -// param and keep filtering hidden entries, so this stays backward-compatible. -function searchSubdirectories( - path: string, - includeHidden: boolean, -): Promise { - if (!includeHidden) { - return getFileClient().searchSubdirectories(path); - } - return new HttpClient(getAgentServerHttpClientOptions()) - .get(SEARCH_SUBDIRS_PATH, { - params: { path, include_hidden: true }, - }) - .then((response) => response.data); -} - -function getHomeDirectory( - includeHidden: boolean, -): Promise { - if (!includeHidden) { - return getFileClient().getHome(); - } - return new HttpClient(getAgentServerHttpClientOptions()) - .get(HOME_PATH, { - params: { include_hidden: true }, - }) - .then((response) => response.data); -} - export const useSearchSubdirs = ( path: string | null, includeHidden = false, @@ -86,7 +33,12 @@ export const useSearchSubdirs = ( active.backend.id, active.orgId, ], - queryFn: () => searchSubdirectories(path as string, includeHidden), + queryFn: () => + includeHidden + ? fileBrowserApi.searchSubdirectoriesWithHidden({ + path: path as string, + }) + : getFileClient().searchSubdirectories(path as string), enabled: !!path, retry: false, meta: { disableToast: true }, @@ -98,7 +50,9 @@ export const useHomeDirectory = (includeHidden = false) => { return useQuery({ queryKey: ["file", "home", includeHidden, active.backend.id, active.orgId], queryFn: async (): Promise => - getHomeDirectory(includeHidden), + includeHidden + ? fileBrowserApi.getHomeWithHidden() + : getFileClient().getHome(), retry: false, meta: { disableToast: true }, staleTime: Infinity, From 45930a707400886f8295c57956b1a00d8f4df3f7 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 19 Jun 2026 16:32:05 -0400 Subject: [PATCH 3/3] test(workspace): cover hidden-folder HTTP bridge directly + encode local params Addresses all-hands-bot review feedback on #1364: - Add focused tests for src/api/file-browser/file-browser-api.ts that mock axios/callCloudProxy and assert the outgoing URL carries include_hidden=true, the local path encodes page_id/limit (not the camelCase pageId), cloud backends route through callCloudProxy, and the interceptor sets X-Session-API-Key + baseURL from the effective local backend (and throws when none is available). - Encode the local axios params explicitly instead of spreading the options object, so camelCase fields (pageId) never leak onto the wire. Co-authored-by: openhands --- __tests__/api/file-browser-api.test.ts | 205 +++++++++++++++++++++++ src/api/file-browser/file-browser-api.ts | 30 ++-- 2 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 __tests__/api/file-browser-api.test.ts diff --git a/__tests__/api/file-browser-api.test.ts b/__tests__/api/file-browser-api.test.ts new file mode 100644 index 000000000..5d365080b --- /dev/null +++ b/__tests__/api/file-browser-api.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Backend } from "#/api/backend-registry/types"; +import type { InternalAxiosRequestConfig } from "axios"; + +const { + mockGet, + mockCallCloudProxy, + mockGetActive, + mockGetEffectiveLocal, + capturedInterceptors, +} = vi.hoisted(() => { + const interceptors: Array< + (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig + > = []; + return { + mockGet: vi.fn(), + mockCallCloudProxy: vi.fn(), + mockGetActive: vi.fn(), + mockGetEffectiveLocal: vi.fn(), + capturedInterceptors: interceptors, + }; +}); + +vi.mock("axios", () => ({ + default: { + create: () => ({ + get: mockGet, + interceptors: { + request: { + use: ( + fn: ( + config: InternalAxiosRequestConfig, + ) => InternalAxiosRequestConfig, + ) => { + capturedInterceptors.push(fn); + }, + }, + }, + }), + }, +})); + +vi.mock("#/api/cloud/proxy", () => ({ + callCloudProxy: mockCallCloudProxy, +})); + +vi.mock("#/api/backend-registry/active-store", () => ({ + getActiveBackend: mockGetActive, + getEffectiveLocalBackend: mockGetEffectiveLocal, +})); + +import { fileBrowserApi } from "#/api/file-browser/file-browser-api"; + +const localBackend: Backend = { + id: "local-1", + name: "Local", + host: "http://localhost:8000", + apiKey: "session-key", + kind: "local", +}; + +const cloudBackend: Backend = { + id: "cloud-1", + name: "Production", + host: "https://app.all-hands.dev", + apiKey: "bearer-key", + kind: "cloud", +}; + +function makeAxiosConfig( + overrides: Partial = {}, +): InternalAxiosRequestConfig { + const headers = { + set: vi.fn(), + get: vi.fn(), + } as unknown as InternalAxiosRequestConfig["headers"]; + return { + headers, + ...overrides, + } as unknown as InternalAxiosRequestConfig; +} + +describe("fileBrowserApi", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockGet.mockReset(); + mockCallCloudProxy.mockReset(); + mockGetActive.mockReset(); + mockGetActive.mockReturnValue({ backend: localBackend, orgId: null }); + mockGetEffectiveLocal.mockReset(); + mockGetEffectiveLocal.mockReturnValue(localBackend); + }); + + describe("searchSubdirectoriesWithHidden", () => { + it("sends include_hidden=true via the local axios path", async () => { + mockGet.mockResolvedValue({ + data: { + items: [{ name: ".config", path: "/h/.config" }], + next_page_id: null, + }, + }); + + const result = await fileBrowserApi.searchSubdirectoriesWithHidden({ + path: "/home/me", + }); + + expect(mockGet).toHaveBeenCalledTimes(1); + const [url] = mockGet.mock.calls[0]; + expect(url).toBe( + "/api/file/search_subdirs?path=%2Fhome%2Fme&include_hidden=true", + ); + expect(result.items[0]?.name).toBe(".config"); + }); + + it("encodes pagination fields as page_id/limit and not pageId", async () => { + mockGet.mockResolvedValue({ data: { items: [], next_page_id: null } }); + + await fileBrowserApi.searchSubdirectoriesWithHidden({ + path: "/home/me", + pageId: "p2", + limit: 25, + }); + + const [url] = mockGet.mock.calls[0]; + expect(url).toContain("page_id=p2"); + expect(url).toContain("limit=25"); + expect(url).not.toContain("pageId"); + }); + + it("routes through callCloudProxy for cloud backends", async () => { + mockGetActive.mockReturnValue({ backend: cloudBackend, orgId: null }); + mockCallCloudProxy.mockResolvedValue({ + items: [{ name: ".cache", path: "/h/.cache" }], + next_page_id: null, + }); + + await fileBrowserApi.searchSubdirectoriesWithHidden({ path: "/home/me" }); + + expect(mockGet).not.toHaveBeenCalled(); + expect(mockCallCloudProxy).toHaveBeenCalledWith({ + backend: cloudBackend, + method: "GET", + path: "/api/file/search_subdirs?path=%2Fhome%2Fme&include_hidden=true", + }); + }); + }); + + describe("getHomeWithHidden", () => { + it("sends include_hidden=true via the local axios path", async () => { + mockGet.mockResolvedValue({ + data: { + home: "/home/me", + favorites: [{ label: ".cache", path: "/home/me/.cache" }], + }, + }); + + const result = await fileBrowserApi.getHomeWithHidden(); + + const [url] = mockGet.mock.calls[0]; + expect(url).toBe("/api/file/home?include_hidden=true"); + expect(result.favorites?.[0]?.label).toBe(".cache"); + }); + + it("routes through callCloudProxy for cloud backends", async () => { + mockGetActive.mockReturnValue({ backend: cloudBackend, orgId: null }); + mockCallCloudProxy.mockResolvedValue({ home: "/home/me" }); + + await fileBrowserApi.getHomeWithHidden(); + + expect(mockGet).not.toHaveBeenCalled(); + expect(mockCallCloudProxy).toHaveBeenCalledWith({ + backend: cloudBackend, + method: "GET", + path: "/api/file/home?include_hidden=true", + }); + }); + }); + + describe("localFileAxios interceptor", () => { + it("sets X-Session-API-Key from the effective local backend", () => { + const interceptor = capturedInterceptors[0]; + expect(interceptor).toBeDefined(); + const config = makeAxiosConfig(); + interceptor(config); + expect(config.headers.set).toHaveBeenCalledWith( + "X-Session-API-Key", + "session-key", + ); + }); + + it("sets baseURL from the effective local backend", () => { + const interceptor = capturedInterceptors[0]; + const config = makeAxiosConfig(); + interceptor(config); + expect(config.baseURL).toBe("http://localhost:8000"); + }); + + it("throws when no local backend is available", () => { + mockGetEffectiveLocal.mockReturnValue(null); + const interceptor = capturedInterceptors[0]; + const config = makeAxiosConfig(); + expect(() => interceptor(config)).toThrow(); + }); + }); +}); diff --git a/src/api/file-browser/file-browser-api.ts b/src/api/file-browser/file-browser-api.ts index 207d61854..ff1b115e7 100644 --- a/src/api/file-browser/file-browser-api.ts +++ b/src/api/file-browser/file-browser-api.ts @@ -52,27 +52,33 @@ interface HiddenSearchParams { limit?: number; } +function buildSearchParams(params: HiddenSearchParams): URLSearchParams { + const query = new URLSearchParams({ + path: params.path, + include_hidden: "true", + }); + if (params.pageId) query.set("page_id", params.pageId); + if (params.limit !== undefined) query.set("limit", String(params.limit)); + return query; +} + async function searchSubdirectoriesWithHidden( params: HiddenSearchParams, ): Promise { const active = getActiveBackend(); if (active.backend.kind === "cloud") { - const query = new URLSearchParams({ - path: params.path, - include_hidden: "true", - }); - if (params.pageId) query.set("page_id", params.pageId); - if (params.limit !== undefined) query.set("limit", String(params.limit)); return callCloudProxy({ backend: active.backend, method: "GET", - path: `${SEARCH_SUBDIRS_PATH}?${query.toString()}`, + path: `${SEARCH_SUBDIRS_PATH}?${buildSearchParams(params).toString()}`, }); } + // Encode params explicitly (path, page_id, limit, include_hidden) rather + // than spreading the options object, so callers' camelCase fields do not + // leak onto the wire as-is. const response = await localFileAxios.get( - SEARCH_SUBDIRS_PATH, - { params: { ...params, include_hidden: true } }, + `${SEARCH_SUBDIRS_PATH}?${buildSearchParams(params).toString()}`, ); return response.data; } @@ -87,9 +93,9 @@ async function getHomeWithHidden(): Promise { }); } - const response = await localFileAxios.get(HOME_PATH, { - params: { include_hidden: true }, - }); + const response = await localFileAxios.get( + `${HOME_PATH}?include_hidden=true`, + ); return response.data; }