mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 08:09:16 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452f5e2214 | ||
|
|
6807f915e1 | ||
|
|
2e61fbc889 | ||
|
|
0e42166bb1 |
+1
-2
@@ -133,8 +133,7 @@ or a result you must retain.
|
|||||||
|
|
||||||
Use the workspace picker before starting project-specific work. This gives the
|
Use the workspace picker before starting project-specific work. This gives the
|
||||||
agent the right project context for file paths, shell commands, and session
|
agent the right project context for file paths, shell commands, and session
|
||||||
metadata. A locally hosted WebUI opens the operating system's folder chooser
|
metadata.
|
||||||
when one is available; remote deployments keep the manual absolute path entry.
|
|
||||||
|
|
||||||
Selecting a project does not replace the configured agent workspace. The two
|
Selecting a project does not replace the configured agent workspace. The two
|
||||||
paths have different responsibilities:
|
paths have different responsibilities:
|
||||||
|
|||||||
@@ -3266,85 +3266,6 @@ 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:
|
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
|
||||||
from nanobot.webui.http_utils import is_local_browser_request
|
from nanobot.webui.http_utils import is_local_browser_request
|
||||||
|
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
"""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
|
|
||||||
_COMMON_ENV_KEYS = (
|
|
||||||
"HOME",
|
|
||||||
"LANG",
|
|
||||||
"LANGUAGE",
|
|
||||||
"LC_ALL",
|
|
||||||
"LC_CTYPE",
|
|
||||||
"LC_MESSAGES",
|
|
||||||
"LOGNAME",
|
|
||||||
"PATH",
|
|
||||||
"SHELL",
|
|
||||||
"TMPDIR",
|
|
||||||
"USER",
|
|
||||||
)
|
|
||||||
_LINUX_GUI_ENV_KEYS = (
|
|
||||||
"DBUS_SESSION_BUS_ADDRESS",
|
|
||||||
"DESKTOP_SESSION",
|
|
||||||
"DISPLAY",
|
|
||||||
"WAYLAND_DISPLAY",
|
|
||||||
"XAUTHORITY",
|
|
||||||
"XDG_CURRENT_DESKTOP",
|
|
||||||
"XDG_RUNTIME_DIR",
|
|
||||||
)
|
|
||||||
_MACOS_GUI_ENV_KEYS = ("SECURITYSESSIONID", "__CF_USER_TEXT_ENCODING")
|
|
||||||
_WINDOWS_GUI_ENV_KEYS = (
|
|
||||||
"APPDATA",
|
|
||||||
"COMSPEC",
|
|
||||||
"HOMEDRIVE",
|
|
||||||
"HOMEPATH",
|
|
||||||
"LOCALAPPDATA",
|
|
||||||
"PATHEXT",
|
|
||||||
"ProgramData",
|
|
||||||
"ProgramFiles",
|
|
||||||
"ProgramFiles(x86)",
|
|
||||||
"ProgramW6432",
|
|
||||||
"SESSIONNAME",
|
|
||||||
"SYSTEMROOT",
|
|
||||||
"TEMP",
|
|
||||||
"TMP",
|
|
||||||
"USERDOMAIN",
|
|
||||||
"USERNAME",
|
|
||||||
"USERPROFILE",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _picker_environment() -> dict[str, str]:
|
|
||||||
"""Pass only host UI/runtime variables, never provider or gateway secrets."""
|
|
||||||
keys: list[str] = list(_COMMON_ENV_KEYS)
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
keys.extend(_MACOS_GUI_ENV_KEYS)
|
|
||||||
elif sys.platform == "win32":
|
|
||||||
keys.extend(_WINDOWS_GUI_ENV_KEYS)
|
|
||||||
elif sys.platform.startswith("linux"):
|
|
||||||
keys.extend(_LINUX_GUI_ENV_KEYS)
|
|
||||||
return {
|
|
||||||
key: value
|
|
||||||
for key in keys
|
|
||||||
if (value := os.environ.get(key)) 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")
|
|
||||||
|
|
||||||
try:
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
*command.argv,
|
|
||||||
env=_picker_environment(),
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
raise NativeFolderPickerError("native folder picker failed to start") from exc
|
|
||||||
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)
|
|
||||||
@@ -149,7 +149,6 @@ def workspaces_payload(
|
|||||||
default_workspace: Path,
|
default_workspace: Path,
|
||||||
default_restrict_to_workspace: bool,
|
default_restrict_to_workspace: bool,
|
||||||
controls_available: bool,
|
controls_available: bool,
|
||||||
folder_picker_available: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
default_access_mode = read_webui_default_access_mode()
|
default_access_mode = read_webui_default_access_mode()
|
||||||
default_scope = (
|
default_scope = (
|
||||||
@@ -168,7 +167,6 @@ def workspaces_payload(
|
|||||||
"controls": {
|
"controls": {
|
||||||
"can_change_project": controls_available,
|
"can_change_project": controls_available,
|
||||||
"can_use_full_access": controls_available,
|
"can_use_full_access": controls_available,
|
||||||
"can_pick_folder": folder_picker_available,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,17 +241,11 @@ class WebUIWorkspaceController:
|
|||||||
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
|
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
|
||||||
)
|
)
|
||||||
|
|
||||||
def payload(
|
def payload(self, *, controls_available: bool) -> dict[str, Any]:
|
||||||
self,
|
|
||||||
*,
|
|
||||||
controls_available: bool,
|
|
||||||
folder_picker_available: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return workspaces_payload(
|
return workspaces_payload(
|
||||||
default_workspace=self._default_workspace,
|
default_workspace=self._default_workspace,
|
||||||
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
||||||
controls_available=controls_available,
|
controls_available=controls_available,
|
||||||
folder_picker_available=folder_picker_available,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def scope_from_envelope(
|
def scope_from_envelope(
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ from nanobot.webui.http_utils import (
|
|||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
is_localhost as _is_localhost,
|
is_localhost as _is_localhost,
|
||||||
)
|
)
|
||||||
from nanobot.webui.http_utils import is_loopback_host as _is_loopback_host
|
|
||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
|
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
|
||||||
)
|
)
|
||||||
@@ -86,11 +85,6 @@ from nanobot.webui.http_utils import (
|
|||||||
)
|
)
|
||||||
from nanobot.webui.ingress_policy import WebUIIngressPolicy
|
from nanobot.webui.ingress_policy import WebUIIngressPolicy
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
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 (
|
from nanobot.webui.session_automations import (
|
||||||
all_automations_payload,
|
all_automations_payload,
|
||||||
serialize_automation_jobs,
|
serialize_automation_jobs,
|
||||||
@@ -139,7 +133,6 @@ _WEBUI_MUTATION_PATHS = {
|
|||||||
"skill.update": "/api/webui/skills/update",
|
"skill.update": "/api/webui/skills/update",
|
||||||
"skill.delete": "/api/webui/skills/delete",
|
"skill.delete": "/api/webui/skills/delete",
|
||||||
"sidebar.update": "/api/webui/sidebar-state/update",
|
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||||
"workspace.pick_folder": "/api/workspaces/pick-folder",
|
|
||||||
"settings.agent.update": "/api/settings/update",
|
"settings.agent.update": "/api/settings/update",
|
||||||
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||||
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||||
@@ -336,7 +329,6 @@ class GatewayHTTPHandler:
|
|||||||
)
|
)
|
||||||
self.skill_state_action = skill_state_action
|
self.skill_state_action = skill_state_action
|
||||||
self._skill_install_lock = asyncio.Lock()
|
self._skill_install_lock = asyncio.Lock()
|
||||||
self._folder_picker_lock = asyncio.Lock()
|
|
||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
self.local_trigger_store = local_trigger_store
|
self.local_trigger_store = local_trigger_store
|
||||||
self.cron_pending_job_ids = cron_pending_job_ids
|
self.cron_pending_job_ids = cron_pending_job_ids
|
||||||
@@ -368,17 +360,6 @@ class GatewayHTTPHandler:
|
|||||||
def workspace_controls_available(self, connection: Any) -> bool:
|
def workspace_controls_available(self, connection: Any) -> bool:
|
||||||
return self._runtime_surface == "native" or _is_localhost(connection)
|
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 ---------------------------------------------------
|
# -- Token management ---------------------------------------------------
|
||||||
|
|
||||||
def check_api_token(self, request: WsRequest) -> bool:
|
def check_api_token(self, request: WsRequest) -> bool:
|
||||||
@@ -454,7 +435,6 @@ class GatewayHTTPHandler:
|
|||||||
"/api/webui/skills/update",
|
"/api/webui/skills/update",
|
||||||
"/api/webui/skills/delete",
|
"/api/webui/skills/delete",
|
||||||
"/api/webui/sidebar-state/update",
|
"/api/webui/sidebar-state/update",
|
||||||
"/api/workspaces/pick-folder",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1074,8 +1054,6 @@ class GatewayHTTPHandler:
|
|||||||
return await self._handle_sessions_list(request)
|
return await self._handle_sessions_list(request)
|
||||||
if got == "/api/commands":
|
if got == "/api/commands":
|
||||||
return self._handle_commands(request)
|
return self._handle_commands(request)
|
||||||
if got == "/api/workspaces/pick-folder":
|
|
||||||
return await self._handle_workspace_folder_picker(connection, request)
|
|
||||||
if got == "/api/workspaces":
|
if got == "/api/workspaces":
|
||||||
return self._handle_workspaces(connection, request)
|
return self._handle_workspaces(connection, request)
|
||||||
if got == "/api/webui/skills/search":
|
if got == "/api/webui/skills/search":
|
||||||
@@ -1111,32 +1089,10 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
return _http_json_response(
|
return _http_json_response(
|
||||||
self.workspaces.payload(
|
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:
|
def _handle_webui_skills(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.webui import native_folder_picker as picker
|
|
||||||
|
|
||||||
|
|
||||||
def _picker_command(tmp_path: Path, body: str) -> picker._PickerCommand:
|
|
||||||
script = tmp_path / "picker.py"
|
|
||||||
script.write_text(f"{body}\n", encoding="utf-8")
|
|
||||||
return picker._PickerCommand((sys.executable, str(script)), frozenset({1}))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pick_native_folder_returns_selected_directory(tmp_path, monkeypatch) -> None:
|
|
||||||
selected = tmp_path / "project"
|
|
||||||
selected.mkdir()
|
|
||||||
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
|
|
||||||
monkeypatch.setattr(
|
|
||||||
picker,
|
|
||||||
"_picker_command",
|
|
||||||
lambda: command,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await picker.pick_native_folder() == str(selected)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pick_native_folder_uses_secret_free_environment(tmp_path, monkeypatch) -> None:
|
|
||||||
selected = tmp_path / "project"
|
|
||||||
selected.mkdir()
|
|
||||||
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
|
|
||||||
monkeypatch.setattr(picker, "_picker_command", lambda: command)
|
|
||||||
monkeypatch.setenv("HOME", str(tmp_path))
|
|
||||||
monkeypatch.setenv("OPENAI_API_KEY", "must-not-reach-picker")
|
|
||||||
original_spawn = picker.asyncio.create_subprocess_exec
|
|
||||||
captured_env: dict[str, str] = {}
|
|
||||||
|
|
||||||
async def capture_spawn(*args, **kwargs):
|
|
||||||
captured_env.update(kwargs["env"])
|
|
||||||
return await original_spawn(*args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", capture_spawn)
|
|
||||||
|
|
||||||
assert await picker.pick_native_folder() == str(selected)
|
|
||||||
assert captured_env["HOME"] == str(tmp_path)
|
|
||||||
assert "OPENAI_API_KEY" not in captured_env
|
|
||||||
|
|
||||||
|
|
||||||
def test_picker_environment_preserves_linux_display_context(monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr(picker.sys, "platform", "linux")
|
|
||||||
monkeypatch.setenv("DISPLAY", ":42")
|
|
||||||
monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/test/bus")
|
|
||||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-reach-picker")
|
|
||||||
|
|
||||||
environment = picker._picker_environment()
|
|
||||||
|
|
||||||
assert environment["DISPLAY"] == ":42"
|
|
||||||
assert environment["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/run/user/test/bus"
|
|
||||||
assert "ANTHROPIC_API_KEY" not in environment
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pick_native_folder_maps_dialog_cancel_to_none(tmp_path, monkeypatch) -> None:
|
|
||||||
command = _picker_command(tmp_path, "raise SystemExit(1)")
|
|
||||||
monkeypatch.setattr(
|
|
||||||
picker,
|
|
||||||
"_picker_command",
|
|
||||||
lambda: command,
|
|
||||||
)
|
|
||||||
|
|
||||||
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"
|
|
||||||
command = _picker_command(tmp_path, f"print({str(missing)!r}, end='')")
|
|
||||||
monkeypatch.setattr(
|
|
||||||
picker,
|
|
||||||
"_picker_command",
|
|
||||||
lambda: command,
|
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pick_native_folder_wraps_process_start_failure(tmp_path, monkeypatch) -> None:
|
|
||||||
command = _picker_command(tmp_path, "raise AssertionError('not started')")
|
|
||||||
monkeypatch.setattr(picker, "_picker_command", lambda: command)
|
|
||||||
|
|
||||||
async def fail_spawn(*args, **kwargs):
|
|
||||||
raise OSError("executable disappeared")
|
|
||||||
|
|
||||||
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", fail_spawn)
|
|
||||||
|
|
||||||
with pytest.raises(picker.NativeFolderPickerError, match="failed to start"):
|
|
||||||
await picker.pick_native_folder()
|
|
||||||
@@ -72,7 +72,6 @@ def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> N
|
|||||||
assert payload["default_scope"]["access_mode"] == "full"
|
assert payload["default_scope"]["access_mode"] == "full"
|
||||||
assert payload["default_access_mode"] == "default"
|
assert payload["default_access_mode"] == "default"
|
||||||
assert payload["controls"]["can_change_project"] is True
|
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(
|
def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
||||||
@@ -92,22 +91,6 @@ def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
|||||||
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
||||||
assert payload["controls"]["can_change_project"] is False
|
assert payload["controls"]["can_change_project"] is False
|
||||||
assert payload["controls"]["can_use_full_access"] 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:
|
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
|
||||||
|
|||||||
@@ -217,7 +217,6 @@ interface ThreadComposerProps {
|
|||||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||||
workspaceScopeDisabled?: boolean;
|
workspaceScopeDisabled?: boolean;
|
||||||
workspaceError?: string | null;
|
workspaceError?: string | null;
|
||||||
onPickWorkspaceFolder?: () => Promise<string | null>;
|
|
||||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||||
pendingQueueKey?: string | null;
|
pendingQueueKey?: string | null;
|
||||||
transcriptionProvider?: string | null;
|
transcriptionProvider?: string | null;
|
||||||
@@ -971,7 +970,6 @@ export function ThreadComposer({
|
|||||||
workspaceControls = null,
|
workspaceControls = null,
|
||||||
workspaceScopeDisabled = false,
|
workspaceScopeDisabled = false,
|
||||||
workspaceError = null,
|
workspaceError = null,
|
||||||
onPickWorkspaceFolder,
|
|
||||||
onWorkspaceScopeChange,
|
onWorkspaceScopeChange,
|
||||||
pendingQueueKey = null,
|
pendingQueueKey = null,
|
||||||
transcriptionProvider = null,
|
transcriptionProvider = null,
|
||||||
@@ -2602,7 +2600,6 @@ export function ThreadComposer({
|
|||||||
defaultScope={workspaceDefaultScope}
|
defaultScope={workspaceDefaultScope}
|
||||||
controls={workspaceControls}
|
controls={workspaceControls}
|
||||||
error={workspaceError}
|
error={workspaceError}
|
||||||
onPickFolder={onPickWorkspaceFolder}
|
|
||||||
onChange={onWorkspaceScopeChange}
|
onChange={onWorkspaceScopeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -661,14 +661,6 @@ export function ThreadShell({
|
|||||||
forkBoundaryMessageCount,
|
forkBoundaryMessageCount,
|
||||||
} = useSessionHistory(historyKey);
|
} = useSessionHistory(historyKey);
|
||||||
const { client, getToken, ingressLimits, modelName, token } = useClient();
|
const { client, getToken, ingressLimits, modelName, token } = useClient();
|
||||||
const pickWorkspaceFolder = useCallback(async (): Promise<string | null> => {
|
|
||||||
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<string | null>(null);
|
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||||
const [booting, setBooting] = useState(false);
|
const [booting, setBooting] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
@@ -1466,9 +1458,6 @@ export function ThreadShell({
|
|||||||
workspaceControls={workspaceControls}
|
workspaceControls={workspaceControls}
|
||||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
onPickWorkspaceFolder={
|
|
||||||
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
|
|
||||||
}
|
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
pendingQueueKey={temporary ? null : chatId}
|
pendingQueueKey={temporary ? null : chatId}
|
||||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||||
@@ -1514,9 +1503,6 @@ export function ThreadShell({
|
|||||||
workspaceControls={workspaceControls}
|
workspaceControls={workspaceControls}
|
||||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
onPickWorkspaceFolder={
|
|
||||||
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
|
|
||||||
}
|
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||||
ingressLimits={ingressLimits}
|
ingressLimits={ingressLimits}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export function WorkspaceProjectPicker({
|
|||||||
defaultScope,
|
defaultScope,
|
||||||
controls,
|
controls,
|
||||||
error,
|
error,
|
||||||
onPickFolder,
|
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
@@ -62,7 +61,6 @@ export function WorkspaceProjectPicker({
|
|||||||
defaultScope: WorkspaceScopePayload | null;
|
defaultScope: WorkspaceScopePayload | null;
|
||||||
controls: WorkspacesPayload["controls"] | null;
|
controls: WorkspacesPayload["controls"] | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
onPickFolder?: () => Promise<string | null>;
|
|
||||||
onChange?: (scope: WorkspaceScopePayload) => void;
|
onChange?: (scope: WorkspaceScopePayload) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -81,7 +79,7 @@ export function WorkspaceProjectPicker({
|
|||||||
&& !!defaultScope
|
&& !!defaultScope
|
||||||
&& !!onChange
|
&& !!onChange
|
||||||
&& controls?.can_change_project !== false;
|
&& controls?.can_change_project !== false;
|
||||||
const pickFolder = getRuntimeHost().pickFolder ?? onPickFolder;
|
const pickFolder = getRuntimeHost().pickFolder;
|
||||||
const nativeProjectPicker = !!pickFolder;
|
const nativeProjectPicker = !!pickFolder;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -363,7 +363,6 @@ export interface WorkspacesPayload {
|
|||||||
controls: {
|
controls: {
|
||||||
can_change_project: boolean;
|
can_change_project: boolean;
|
||||||
can_use_full_access: boolean;
|
can_use_full_access: boolean;
|
||||||
can_pick_folder?: boolean;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1260,45 +1260,6 @@ 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(
|
|
||||||
<ThreadComposer
|
|
||||||
onSend={vi.fn()}
|
|
||||||
placeholder="Ask anything..."
|
|
||||||
variant="hero"
|
|
||||||
workspaceScope={defaultScope}
|
|
||||||
workspaceDefaultScope={defaultScope}
|
|
||||||
workspaceControls={{
|
|
||||||
can_change_project: true,
|
|
||||||
can_use_full_access: true,
|
|
||||||
can_pick_folder: true,
|
|
||||||
}}
|
|
||||||
onPickWorkspaceFolder={pickFolder}
|
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
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 () => {
|
it("uses the web path menu when no native host picker is available", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const defaultScope = {
|
const defaultScope = {
|
||||||
|
|||||||
Reference in New Issue
Block a user