fix(webui): isolate folder picker environment

This commit is contained in:
Xubin Ren
2026-08-14 04:03:54 +09:00
parent 9703656b25
commit e3d1819a2b
3 changed files with 118 additions and 6 deletions
+2 -1
View File
@@ -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:
+62
View File
@@ -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(),
+49
View File
@@ -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()