mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 08:09:16 +03:00
fix(webui): isolate folder picker environment
This commit is contained in:
+2
-1
@@ -133,7 +133,8 @@ 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.
|
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
|
Selecting a project does not replace the configured agent workspace. The two
|
||||||
paths have different responsibilities:
|
paths have different responsibilities:
|
||||||
|
|||||||
@@ -11,6 +11,48 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_PICKER_TIMEOUT_SECONDS = 300
|
_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):
|
class NativeFolderPickerError(RuntimeError):
|
||||||
@@ -94,6 +136,22 @@ def native_folder_picker_available() -> bool:
|
|||||||
return _picker_command() is not None
|
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:
|
async def _stop_process(process: asyncio.subprocess.Process) -> None:
|
||||||
if process.returncode is not None:
|
if process.returncode is not None:
|
||||||
return
|
return
|
||||||
@@ -113,11 +171,15 @@ async def pick_native_folder() -> str | None:
|
|||||||
if command is None:
|
if command is None:
|
||||||
raise NativeFolderPickerError("native folder picker is unavailable on this host")
|
raise NativeFolderPickerError("native folder picker is unavailable on this host")
|
||||||
|
|
||||||
|
try:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*command.argv,
|
*command.argv,
|
||||||
|
env=_picker_environment(),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise NativeFolderPickerError("native folder picker failed to start") from exc
|
||||||
try:
|
try:
|
||||||
stdout, stderr = await asyncio.wait_for(
|
stdout, stderr = await asyncio.wait_for(
|
||||||
process.communicate(),
|
process.communicate(),
|
||||||
|
|||||||
@@ -28,6 +28,41 @@ async def test_pick_native_folder_returns_selected_directory(tmp_path, monkeypat
|
|||||||
assert await picker.pick_native_folder() == str(selected)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_pick_native_folder_maps_dialog_cancel_to_none(tmp_path, monkeypatch) -> None:
|
async def test_pick_native_folder_maps_dialog_cancel_to_none(tmp_path, monkeypatch) -> None:
|
||||||
command = _picker_command(tmp_path, "raise SystemExit(1)")
|
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
|
assert picker.native_folder_picker_available() is False
|
||||||
with pytest.raises(picker.NativeFolderPickerError, match="unavailable"):
|
with pytest.raises(picker.NativeFolderPickerError, match="unavailable"):
|
||||||
await picker.pick_native_folder()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user