diff --git a/docs/webui.md b/docs/webui.md index cafb57b77..e8330dbf8 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -133,7 +133,8 @@ or a result you must retain. Use the workspace picker before starting project-specific work. This gives the agent the right project context for file paths, shell commands, and session -metadata. +metadata. A locally hosted WebUI opens the operating system's folder chooser +when one is available; remote deployments keep the manual absolute path entry. Selecting a project does not replace the configured agent workspace. The two paths have different responsibilities: diff --git a/nanobot/webui/native_folder_picker.py b/nanobot/webui/native_folder_picker.py index 02162eb3c..37a91dbcc 100644 --- a/nanobot/webui/native_folder_picker.py +++ b/nanobot/webui/native_folder_picker.py @@ -11,6 +11,48 @@ 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): @@ -94,6 +136,22 @@ def native_folder_picker_available() -> bool: 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 @@ -113,11 +171,15 @@ async def pick_native_folder() -> str | None: 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: + 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(), diff --git a/tests/utils/test_native_folder_picker.py b/tests/utils/test_native_folder_picker.py index f62a605f9..feb5a26de 100644 --- a/tests/utils/test_native_folder_picker.py +++ b/tests/utils/test_native_folder_picker.py @@ -28,6 +28,41 @@ async def test_pick_native_folder_returns_selected_directory(tmp_path, monkeypat 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)") @@ -61,3 +96,17 @@ async def test_pick_native_folder_reports_unavailable(monkeypatch) -> 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()