From 26c9687b80ac5aad43e05623d93ff665955dec31 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:44:17 +0900 Subject: [PATCH] feat(webui): add native workspace folder picker --- .../tests/test_websocket_http_routes.py | 79 ++++++++++ nanobot/webui/native_folder_picker.py | 149 ++++++++++++++++++ nanobot/webui/workspaces.py | 10 +- nanobot/webui/ws_http.py | 46 +++++- tests/utils/test_native_folder_picker.py | 63 ++++++++ tests/utils/test_webui_workspaces.py | 17 ++ .../src/components/thread/ThreadComposer.tsx | 3 + webui/src/components/thread/ThreadShell.tsx | 14 ++ .../components/thread/WorkspaceControls.tsx | 4 +- webui/src/lib/types.ts | 1 + webui/src/tests/thread-composer.test.tsx | 39 +++++ 11 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 nanobot/webui/native_folder_picker.py create mode 100644 tests/utils/test_native_folder_picker.py diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index fd93b9074..e583047ae 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -3266,6 +3266,85 @@ async def _webui_mutate( ) +@pytest.mark.asyncio +async def test_workspace_folder_picker_is_local_authenticated_mutation( + bus: MagicMock, + tmp_path: Path, + monkeypatch, +) -> None: + selected = tmp_path / "project" + selected.mkdir() + pick_folder = AsyncMock(return_value=str(selected)) + monkeypatch.setattr( + "nanobot.webui.ws_http.native_folder_picker_available", + lambda: True, + ) + monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder) + channel = _ch(bus) + + response = await _webui_mutate(channel, "workspace.pick_folder") + + assert response.status_code == 200 + assert response.json() == {"path": str(selected)} + pick_folder.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_workspace_folder_picker_rejects_direct_http( + bus: MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pick_folder = AsyncMock(return_value="/tmp") + monkeypatch.setattr( + "nanobot.webui.ws_http.native_folder_picker_available", + lambda: True, + ) + monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder) + channel = _ch(bus) + + response = await channel.gateway.http.dispatch( + _LOCAL, + _FakeReq( + {"Host": "127.0.0.1:8765"}, + path="/api/workspaces/pick-folder", + ), + ) + + assert response is not None + assert response.status_code == 405 + assert b"authenticated WebSocket" in response.body + pick_folder.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("connection", "host"), + [(_REMOTE, "127.0.0.1"), (_LOCAL, "0.0.0.0")], +) +async def test_workspace_folder_picker_rejects_nonlocal_surfaces( + bus: MagicMock, + monkeypatch, + connection: _FakeConn, + host: str, +) -> None: + pick_folder = AsyncMock(return_value="/tmp") + monkeypatch.setattr( + "nanobot.webui.ws_http.native_folder_picker_available", + lambda: True, + ) + monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder) + channel = _ch(bus, host=host, token="test-token" if host == "0.0.0.0" else "") + + response = await _webui_mutate( + channel, + "workspace.pick_folder", + connection=connection, + ) + + assert response.status_code == 403 + pick_folder.assert_not_awaited() + + def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None: from nanobot.webui.http_utils import is_local_browser_request diff --git a/nanobot/webui/native_folder_picker.py b/nanobot/webui/native_folder_picker.py new file mode 100644 index 000000000..02162eb3c --- /dev/null +++ b/nanobot/webui/native_folder_picker.py @@ -0,0 +1,149 @@ +"""Native directory picker used by a locally hosted WebUI.""" + +from __future__ import annotations + +import asyncio +import os +import shutil +import sys +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path + +_PICKER_TIMEOUT_SECONDS = 300 + + +class NativeFolderPickerError(RuntimeError): + """Raised when an available native folder picker cannot complete.""" + + +@dataclass(frozen=True) +class _PickerCommand: + argv: tuple[str, ...] + cancel_codes: frozenset[int] + cancel_markers: tuple[str, ...] = () + + +def _picker_command() -> _PickerCommand | None: + if sys.platform == "darwin": + executable = shutil.which("osascript") + if executable is None: + return None + return _PickerCommand( + argv=( + executable, + "-e", + 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', + "-e", + "POSIX path of selectedFolder", + ), + cancel_codes=frozenset({1}), + cancel_markers=("user canceled", "(-128)"), + ) + + if sys.platform == "win32": + executable = shutil.which("powershell.exe") or shutil.which("powershell") + if executable is None: + return None + script = ( + "Add-Type -AssemblyName System.Windows.Forms;" + "$dialog=New-Object System.Windows.Forms.FolderBrowserDialog;" + "$dialog.Description='Select Workspace Directory';" + "$dialog.ShowNewFolderButton=$true;" + "if($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){" + "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new();" + "[Console]::Out.Write($dialog.SelectedPath)}" + ) + return _PickerCommand( + argv=( + executable, + "-NoProfile", + "-NonInteractive", + "-STA", + "-Command", + script, + ), + cancel_codes=frozenset(), + ) + + if sys.platform.startswith("linux"): + if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): + return None + zenity = shutil.which("zenity") + if zenity is not None: + return _PickerCommand( + argv=( + zenity, + "--file-selection", + "--directory", + "--title=Select Workspace Directory", + ), + cancel_codes=frozenset({1}), + ) + kdialog = shutil.which("kdialog") + if kdialog is not None: + return _PickerCommand( + argv=(kdialog, "--getexistingdirectory", str(Path.home())), + cancel_codes=frozenset({1}), + ) + return None + + +def native_folder_picker_available() -> bool: + """Return whether this host can display a native directory picker.""" + return _picker_command() is not None + + +async def _stop_process(process: asyncio.subprocess.Process) -> None: + if process.returncode is not None: + return + with suppress(ProcessLookupError): + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=2) + except TimeoutError: + with suppress(ProcessLookupError): + process.kill() + await process.wait() + + +async def pick_native_folder() -> str | None: + """Open the platform directory picker and return an existing absolute path.""" + command = _picker_command() + if command is None: + raise NativeFolderPickerError("native folder picker is unavailable on this host") + + process = await asyncio.create_subprocess_exec( + *command.argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=_PICKER_TIMEOUT_SECONDS, + ) + except asyncio.CancelledError: + await _stop_process(process) + raise + except TimeoutError as exc: + await _stop_process(process) + raise NativeFolderPickerError("native folder picker timed out") from exc + + error_text = stderr.decode("utf-8", errors="replace").strip() + normalized_error = error_text.lower() + if process.returncode != 0: + if process.returncode in command.cancel_codes and ( + not command.cancel_markers + or any(marker in normalized_error for marker in command.cancel_markers) + ): + return None + raise NativeFolderPickerError("native folder picker failed") + + selected = stdout.decode("utf-8", errors="replace").strip() + if not selected: + return None + path = Path(selected).expanduser() + if not path.is_absolute() or not path.is_dir(): + raise NativeFolderPickerError("native folder picker returned an invalid directory") + return str(path) diff --git a/nanobot/webui/workspaces.py b/nanobot/webui/workspaces.py index 1a4d89780..f2e1c976d 100644 --- a/nanobot/webui/workspaces.py +++ b/nanobot/webui/workspaces.py @@ -149,6 +149,7 @@ def workspaces_payload( default_workspace: Path, default_restrict_to_workspace: bool, controls_available: bool, + folder_picker_available: bool = False, ) -> dict[str, Any]: default_access_mode = read_webui_default_access_mode() default_scope = ( @@ -167,6 +168,7 @@ def workspaces_payload( "controls": { "can_change_project": controls_available, "can_use_full_access": controls_available, + "can_pick_folder": folder_picker_available, }, } @@ -241,11 +243,17 @@ class WebUIWorkspaceController: cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY)) ) - def payload(self, *, controls_available: bool) -> dict[str, Any]: + def payload( + self, + *, + controls_available: bool, + folder_picker_available: bool = False, + ) -> dict[str, Any]: return workspaces_payload( default_workspace=self._default_workspace, default_restrict_to_workspace=self._default_restrict_to_workspace, controls_available=controls_available, + folder_picker_available=folder_picker_available, ) def scope_from_envelope( diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index c74f6f51d..6f7d54439 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -62,6 +62,7 @@ from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import ( is_localhost as _is_localhost, ) +from nanobot.webui.http_utils import is_loopback_host as _is_loopback_host from nanobot.webui.http_utils import ( is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request, ) @@ -85,6 +86,11 @@ from nanobot.webui.http_utils import ( ) from nanobot.webui.ingress_policy import WebUIIngressPolicy from nanobot.webui.media_gateway import WebUIMediaGateway +from nanobot.webui.native_folder_picker import ( + NativeFolderPickerError, + native_folder_picker_available, + pick_native_folder, +) from nanobot.webui.session_automations import ( all_automations_payload, serialize_automation_jobs, @@ -133,6 +139,7 @@ _WEBUI_MUTATION_PATHS = { "skill.update": "/api/webui/skills/update", "skill.delete": "/api/webui/skills/delete", "sidebar.update": "/api/webui/sidebar-state/update", + "workspace.pick_folder": "/api/workspaces/pick-folder", "settings.agent.update": "/api/settings/update", "settings.model_configuration.create": "/api/settings/model-configurations/create", "settings.model_configuration.update": "/api/settings/model-configurations/update", @@ -329,6 +336,7 @@ class GatewayHTTPHandler: ) self.skill_state_action = skill_state_action self._skill_install_lock = asyncio.Lock() + self._folder_picker_lock = asyncio.Lock() self.cron_service = cron_service self.local_trigger_store = local_trigger_store self.cron_pending_job_ids = cron_pending_job_ids @@ -360,6 +368,17 @@ class GatewayHTTPHandler: def workspace_controls_available(self, connection: Any) -> bool: return self._runtime_surface == "native" or _is_localhost(connection) + def workspace_folder_picker_available( + self, + connection: Any, + request: WsRequest, + ) -> bool: + return ( + _is_loopback_host(self.config.host) + and _is_local_browser_request(connection, request.headers) + and native_folder_picker_available() + ) + # -- Token management --------------------------------------------------- def check_api_token(self, request: WsRequest) -> bool: @@ -435,6 +454,7 @@ class GatewayHTTPHandler: "/api/webui/skills/update", "/api/webui/skills/delete", "/api/webui/sidebar-state/update", + "/api/workspaces/pick-folder", } @staticmethod @@ -1054,6 +1074,8 @@ class GatewayHTTPHandler: return await self._handle_sessions_list(request) if got == "/api/commands": return self._handle_commands(request) + if got == "/api/workspaces/pick-folder": + return await self._handle_workspace_folder_picker(connection, request) if got == "/api/workspaces": return self._handle_workspaces(connection, request) if got == "/api/webui/skills/search": @@ -1089,10 +1111,32 @@ class GatewayHTTPHandler: return _http_error(401, "Unauthorized") return _http_json_response( self.workspaces.payload( - controls_available=self.workspace_controls_available(connection) + controls_available=self.workspace_controls_available(connection), + folder_picker_available=self.workspace_folder_picker_available( + connection, + request, + ), ) ) + async def _handle_workspace_folder_picker( + self, + connection: Any, + request: WsRequest, + ) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + if not self.workspace_folder_picker_available(connection, request): + return _http_error(403, "native folder picker is unavailable for this connection") + if self._folder_picker_lock.locked(): + return _http_error(409, "native folder picker is already open") + try: + async with self._folder_picker_lock: + path = await pick_native_folder() + except NativeFolderPickerError as exc: + return _http_error(503, str(exc)) + return _http_json_response({"path": path}) + def _handle_webui_skills(self, request: WsRequest) -> Response: if not self.check_api_token(request): return _http_error(401, "Unauthorized") diff --git a/tests/utils/test_native_folder_picker.py b/tests/utils/test_native_folder_picker.py new file mode 100644 index 000000000..464f52633 --- /dev/null +++ b/tests/utils/test_native_folder_picker.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from nanobot.webui import native_folder_picker as picker + + +def _picker_script(tmp_path: Path, body: str) -> Path: + script = tmp_path / "picker.sh" + script.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") + script.chmod(0o755) + return script + + +@pytest.mark.asyncio +async def test_pick_native_folder_returns_selected_directory(tmp_path, monkeypatch) -> None: + selected = tmp_path / "project" + selected.mkdir() + script = _picker_script(tmp_path, f"printf '%s' '{selected}'") + monkeypatch.setattr( + picker, + "_picker_command", + lambda: picker._PickerCommand((str(script),), frozenset({1})), + ) + + assert await picker.pick_native_folder() == str(selected) + + +@pytest.mark.asyncio +async def test_pick_native_folder_maps_dialog_cancel_to_none(tmp_path, monkeypatch) -> None: + script = _picker_script(tmp_path, "exit 1") + monkeypatch.setattr( + picker, + "_picker_command", + lambda: picker._PickerCommand((str(script),), frozenset({1})), + ) + + assert await picker.pick_native_folder() is None + + +@pytest.mark.asyncio +async def test_pick_native_folder_rejects_non_directory_result(tmp_path, monkeypatch) -> None: + missing = tmp_path / "missing" + script = _picker_script(tmp_path, f"printf '%s' '{missing}'") + monkeypatch.setattr( + picker, + "_picker_command", + lambda: picker._PickerCommand((str(script),), frozenset({1})), + ) + + with pytest.raises(picker.NativeFolderPickerError, match="invalid directory"): + await picker.pick_native_folder() + + +@pytest.mark.asyncio +async def test_pick_native_folder_reports_unavailable(monkeypatch) -> None: + monkeypatch.setattr(picker, "_picker_command", lambda: None) + + assert picker.native_folder_picker_available() is False + with pytest.raises(picker.NativeFolderPickerError, match="unavailable"): + await picker.pick_native_folder() diff --git a/tests/utils/test_webui_workspaces.py b/tests/utils/test_webui_workspaces.py index 634375fcf..639bcb4a6 100644 --- a/tests/utils/test_webui_workspaces.py +++ b/tests/utils/test_webui_workspaces.py @@ -72,6 +72,7 @@ def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> N assert payload["default_scope"]["access_mode"] == "full" assert payload["default_access_mode"] == "default" assert payload["controls"]["can_change_project"] is True + assert payload["controls"]["can_pick_folder"] is False def test_workspace_payload_hides_mutable_state_when_controls_unavailable( @@ -91,6 +92,22 @@ def test_workspace_payload_hides_mutable_state_when_controls_unavailable( assert payload["default_scope"]["project_path"] == str(default.resolve()) assert payload["controls"]["can_change_project"] is False assert payload["controls"]["can_use_full_access"] is False + assert payload["controls"]["can_pick_folder"] is False + + +def test_workspace_payload_advertises_native_folder_picker(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui") + default = tmp_path / "default" + default.mkdir() + + payload = workspaces_payload( + default_workspace=default, + default_restrict_to_workspace=False, + controls_available=True, + folder_picker_available=True, + ) + + assert payload["controls"]["can_pick_folder"] is True def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None: diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 439e9c385..5bc42922d 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -217,6 +217,7 @@ interface ThreadComposerProps { workspaceControls?: WorkspacesPayload["controls"] | null; workspaceScopeDisabled?: boolean; workspaceError?: string | null; + onPickWorkspaceFolder?: () => Promise; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; pendingQueueKey?: string | null; transcriptionProvider?: string | null; @@ -970,6 +971,7 @@ export function ThreadComposer({ workspaceControls = null, workspaceScopeDisabled = false, workspaceError = null, + onPickWorkspaceFolder, onWorkspaceScopeChange, pendingQueueKey = null, transcriptionProvider = null, @@ -2600,6 +2602,7 @@ export function ThreadComposer({ defaultScope={workspaceDefaultScope} controls={workspaceControls} error={workspaceError} + onPickFolder={onPickWorkspaceFolder} onChange={onWorkspaceScopeChange} /> diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index edd97a5f6..2da3dc1c6 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -661,6 +661,14 @@ export function ThreadShell({ forkBoundaryMessageCount, } = useSessionHistory(historyKey); const { client, getToken, ingressLimits, modelName, token } = useClient(); + const pickWorkspaceFolder = useCallback(async (): Promise => { + const response = await client.requestMutation<{ path: unknown }>( + "workspace.pick_folder", + {}, + 300_000, + ); + return typeof response.path === "string" ? response.path : null; + }, [client]); const [fallbackModelName, setFallbackModelName] = useState(null); const [booting, setBooting] = useState(false); const [slashCommands, setSlashCommands] = useState([]); @@ -1458,6 +1466,9 @@ export function ThreadShell({ workspaceControls={workspaceControls} workspaceScopeDisabled={workspaceScopeDisabled} workspaceError={workspaceError} + onPickWorkspaceFolder={ + workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined + } onWorkspaceScopeChange={onWorkspaceScopeChange} pendingQueueKey={temporary ? null : chatId} transcriptionProvider={settingsSnapshot?.transcription?.provider} @@ -1503,6 +1514,9 @@ export function ThreadShell({ workspaceControls={workspaceControls} workspaceScopeDisabled={workspaceScopeDisabled} workspaceError={workspaceError} + onPickWorkspaceFolder={ + workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined + } onWorkspaceScopeChange={onWorkspaceScopeChange} transcriptionProvider={settingsSnapshot?.transcription?.provider} ingressLimits={ingressLimits} diff --git a/webui/src/components/thread/WorkspaceControls.tsx b/webui/src/components/thread/WorkspaceControls.tsx index 60f587f5d..9ec0c9111 100644 --- a/webui/src/components/thread/WorkspaceControls.tsx +++ b/webui/src/components/thread/WorkspaceControls.tsx @@ -51,6 +51,7 @@ export function WorkspaceProjectPicker({ defaultScope, controls, error, + onPickFolder, onChange, }: { isHero: boolean; @@ -61,6 +62,7 @@ export function WorkspaceProjectPicker({ defaultScope: WorkspaceScopePayload | null; controls: WorkspacesPayload["controls"] | null; error?: string | null; + onPickFolder?: () => Promise; onChange?: (scope: WorkspaceScopePayload) => void; }) { const { t } = useTranslation(); @@ -79,7 +81,7 @@ export function WorkspaceProjectPicker({ && !!defaultScope && !!onChange && controls?.can_change_project !== false; - const pickFolder = getRuntimeHost().pickFolder; + const pickFolder = getRuntimeHost().pickFolder ?? onPickFolder; const nativeProjectPicker = !!pickFolder; useEffect(() => { diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 6187ae77d..d32123c28 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -363,6 +363,7 @@ export interface WorkspacesPayload { controls: { can_change_project: boolean; can_use_full_access: boolean; + can_pick_folder?: boolean; }; } diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 4fbab537e..e6d11157e 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1260,6 +1260,45 @@ describe("ThreadComposer", () => { })); }); + it("uses the gateway folder picker for a locally hosted WebUI", async () => { + const onWorkspaceScopeChange = vi.fn(); + const pickFolder = vi.fn().mockResolvedValue("/Users/test/gateway-project"); + const defaultScope = { + project_path: "/Users/test/.nanobot/workspace", + project_name: "workspace", + access_mode: "full" as const, + restrict_to_workspace: false, + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Choose project" })); + + await waitFor(() => expect(pickFolder).toHaveBeenCalled()); + expect(screen.queryByLabelText("Paste path")).not.toBeInTheDocument(); + expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({ + project_path: "/Users/test/gateway-project", + project_name: "gateway-project", + access_mode: "full", + restrict_to_workspace: false, + })); + }); + it("uses the web path menu when no native host picker is available", async () => { const user = userEvent.setup(); const defaultScope = {