From 03fdd25bc0a0dc6a70cffeb3d29cb2ad91865e34 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:13:24 +0200 Subject: [PATCH] Add an opt-in filter for host desktop-session variables RCLOL_FILTER_HOST_ENV=1 makes exec-guest.py drop host session metadata (WAYLAND_DISPLAY, XDG_SESSION_*, XDG_CURRENT_DESKTOP, GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit, Flatpak/Snap/AppImage, exported shell functions) and set SHELL to the guest's /bin/zsh. It is off by default; RCLOL_KEEP_HOST_ENV lists names to keep anyway. Unlike the denylist in #61, the filter runs after the launcher sets its GPU tuning, keeps everything Darling and the host graphics and audio libraries read (DISPLAY, XAUTHORITY, XDG_RUNTIME_DIR and the XDG base directories, D-Bus, PulseAudio/PipeWire/ALSA, DRI_PRIME, MESA_*, __GL_*, __NV_*, VK_*, LD_PRELOAD for gamemoderun, LD_LIBRARY_PATH), and lives in one place instead of three. tests/test_exec_guest.py runs in CI; the launcher check also verifies that MESA_NO_ERROR=1 and __GL_THREADED_OPTIMIZATIONS=0 reach the guest with the filter on. Split from #61. Co-authored-by: DevWebeloper Co-Authored-By: Claude Opus 5.5 --- .github/workflows/source-check.yml | 1 + docs/SETUP.md | 11 ++++ tests/test_exec_guest.py | 90 ++++++++++++++++++++++++++++ tools/exec-guest.py | 43 ++++++++++++- tools/riot-launcher-offline-check.py | 15 ++++- 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 tests/test_exec_guest.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 1413f33..3c5dead 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -18,3 +18,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' - run: python -m unittest discover -s tests -p 'test_desktop_run.py' - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' + - run: python -m unittest discover -s tests -p 'test_exec_guest.py' diff --git a/docs/SETUP.md b/docs/SETUP.md index aa3d7db..4d85c6b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -73,6 +73,17 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) for the observed failures, recovery guards and NSURLSession limitations. +`RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before +the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`, +GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`), +Flatpak/Snap/AppImage and exported shell functions, and `SHELL` becomes `/bin/zsh`. +It is off by default. Variables that Darling or the host graphics and audio libraries read +are always kept: `DISPLAY`, `XAUTHORITY`, `XDG_RUNTIME_DIR` and the XDG base directories, +D-Bus, PulseAudio/PipeWire/ALSA, `DRI_PRIME`, `MESA_*`, `__GL_*`, `__NV_*`, `VK_*`, +`LD_PRELOAD` and `LD_LIBRARY_PATH`. List names to keep anyway in `RCLOL_KEEP_HOST_ENV`, +for example `RCLOL_KEEP_HOST_ENV=WAYLAND_DISPLAY`. The launcher always closes inherited +file descriptors above stderr before the guest starts. + ## Updating an existing runtime `./scripts/update.sh` updates everything after `git pull`: `build.sh`, then the base install, then `install-runtime.sh`. When a pull changes a patch, `prepare.py` finds the earlier version applied in that component (its copy in `build/applied-patches`, or a version from this repository's history), reverts it, and applies the new one. It never resets a repository; if neither version matches, it stops and names the repository with other edits. The steps below update single components instead. diff --git a/tests/test_exec_guest.py b/tests/test_exec_guest.py new file mode 100644 index 0000000..9582245 --- /dev/null +++ b/tests/test_exec_guest.py @@ -0,0 +1,90 @@ +"""exec-guest.py: inherited descriptors and the opt-in host environment filter.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / 'tools/exec-guest.py' +spec = importlib.util.spec_from_file_location('exec_guest', HELPER) +exec_guest = importlib.util.module_from_spec(spec) +spec.loader.exec_module(exec_guest) + +# What the guest and the host libraries Darling loads still need. +REQUIRED = { + 'DISPLAY': ':0', 'XAUTHORITY': '/run/user/1000/.mutter-Xwaylandauth.A', 'XCURSOR_SIZE': '24', + 'XDG_RUNTIME_DIR': '/run/user/1000', 'XDG_DATA_DIRS': '/usr/share', 'XDG_CONFIG_HOME': '/home/u/.config', + 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'PULSE_SERVER': 'unix:/run/pulse', + 'PIPEWIRE_LATENCY': '128/48000', 'ALSA_CONFIG_PATH': '/usr/share/alsa/alsa.conf', + 'DRI_PRIME': '1', 'MESA_NO_ERROR': '1', 'mesa_glthread': 'false', '__GL_THREADED_OPTIMIZATIONS': '0', + '__NV_PRIME_RENDER_OFFLOAD': '1', '__EGL_VENDOR_LIBRARY_FILENAMES': '/usr/share/glvnd/egl_vendor.d/10_nvidia.json', + 'VK_ICD_FILENAMES': '/usr/share/vulkan/icd.d/nvidia_icd.json', 'LIBGL_ALWAYS_SOFTWARE': '0', + 'LD_PRELOAD': 'libgamemodeauto.so.0', 'LD_LIBRARY_PATH': '/run/opengl-driver/lib', + 'HOME': '/home/u', 'USER': 'u', 'PATH': '/usr/bin:/bin', 'LANG': 'en_US.UTF-8', 'TERM': 'xterm', + 'DPREFIX': '/home/u/.darling', 'RCLOL_REFRESH_HZ': '144', 'DYLD_LIBRARY_PATH': '/Users/u/RuntimeLibraries', +} +SESSION = { + 'WAYLAND_DISPLAY': 'wayland-0', 'XDG_CURRENT_DESKTOP': 'GNOME', 'XDG_SESSION_TYPE': 'wayland', + 'XDG_SESSION_ID': '2', 'XDG_SEAT': 'seat0', 'DESKTOP_SESSION': 'gnome', 'GDMSESSION': 'gnome', + 'GDM_LANG': 'en_US.UTF-8', 'SESSION_MANAGER': 'local/host:@/tmp/.ICE-unix/1', 'GNOME_SETUP_DISPLAY': ':1', + 'KDE_FULL_SESSION': 'true', 'SSH_AUTH_SOCK': '/run/user/1000/ssh', 'SYSTEMD_EXEC_PID': '1', + 'INVOCATION_ID': 'abc', 'JOURNAL_STREAM': '8:1', 'MANAGERPID': '1', 'NOTIFY_SOCKET': '/run/notify', + 'LISTEN_FDS': '1', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup', 'QT_QPA_PLATFORM': 'wayland', + 'GTK_MODULES': 'canberra', 'GDK_BACKEND': 'wayland', 'GIO_LAUNCHED_DESKTOP_FILE': '/x.desktop', + 'FLATPAK_ID': 'x', 'SNAP': '/snap/x', 'APPIMAGE': '/x.AppImage', 'VIRTUAL_ENV': '/home/u/.venv', + 'COLORTERM': 'truecolor', 'VTE_VERSION': '7600', 'WINDOWID': '1', 'TERM_PROGRAM': 'vscode', + 'LS_COLORS': 'di=01;34', 'SHLVL': '2', 'BASH_FUNC_f%%': '() { :; }', +} + + +class FilterTests(unittest.TestCase): + def test_strips_session_metadata_and_keeps_runtime_variables(self): + environ = {**REQUIRED, **SESSION, 'SHELL': '/bin/fish'} + removed = exec_guest.filter_host_environment(environ) + self.assertEqual(set(removed), set(SESSION)) + self.assertEqual(environ, {**REQUIRED, 'SHELL': '/bin/zsh'}) + + def test_keep_list(self): + environ = {'WAYLAND_DISPLAY': 'wayland-0', 'SSH_AUTH_SOCK': '/s', 'SHELL': '/bin/fish', + 'RCLOL_KEEP_HOST_ENV': 'WAYLAND_DISPLAY, SHELL'} + self.assertEqual(exec_guest.filter_host_environment(environ), ['SSH_AUTH_SOCK']) + self.assertEqual(environ['WAYLAND_DISPLAY'], 'wayland-0') + self.assertEqual(environ['SHELL'], '/bin/fish') + + +class ExecTests(unittest.TestCase): + DUMP = ('import json, os; print(json.dumps({"env": dict(os.environ), "fds": sorted(' + 'fd for fd in map(int, os.listdir("/proc/self/fd")) if fd < 3 or os.path.exists("/proc/self/fd/%d" % fd))}))') + + def run_helper(self, environ): + leaked = list(os.pipe()) + leaked.append(os.dup2(leaked[0], 41, inheritable=False)) + try: + result = subprocess.run([sys.executable, str(HELPER), sys.executable, '-c', self.DUMP], + env=environ, pass_fds=leaked, capture_output=True, text=True, check=True) + finally: + for fd in leaked: + os.close(fd) + return json.loads(result.stdout) + + def test_closes_descriptors_and_keeps_environment_by_default(self): + environ = {**REQUIRED, **SESSION, 'SHELL': '/bin/fish'} + child = self.run_helper(environ) + self.assertEqual(child['fds'], [0, 1, 2]) + for name, value in environ.items(): + self.assertEqual(child['env'].get(name), value, name) + + def test_opt_in_filter(self): + child = self.run_helper({**REQUIRED, **SESSION, 'SHELL': '/bin/fish', 'RCLOL_FILTER_HOST_ENV': '1'}) + self.assertEqual(child['fds'], [0, 1, 2]) + self.assertFalse(set(SESSION) & set(child['env'])) + for name, value in REQUIRED.items(): + self.assertEqual(child['env'].get(name), value, name) + self.assertEqual(child['env']['SHELL'], '/bin/zsh') + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/exec-guest.py b/tools/exec-guest.py index 2e23c43..331f754 100755 --- a/tools/exec-guest.py +++ b/tools/exec-guest.py @@ -1,8 +1,47 @@ #!/usr/bin/env python3 -"""Run a command without the host file descriptors this launcher inherited (anything above stderr).""" +"""Run a command without the host file descriptors this launcher inherited (anything above stderr). + +With RCLOL_FILTER_HOST_ENV=1 it also drops host desktop-session variables first (see +host_session_variable). RCLOL_KEEP_HOST_ENV lists names to keep anyway, separated by spaces or commas. +""" import os import sys +# Desktop-session, service-manager, terminal and packaging metadata that nothing in the guest reads. +# Everything Darling and the host libraries it loads need is kept: DISPLAY, XAUTHORITY, XCURSOR_*, +# XDG_RUNTIME_DIR, XDG_DATA_DIRS and the other XDG base directories (the Vulkan loader searches +# them), DBUS_SESSION_BUS_ADDRESS, PULSE_*, PIPEWIRE_*, ALSA_*, DRI_PRIME, MESA_*, mesa_glthread, +# __GL_*, __NV_*, __EGL_*, VK_*, LIBGL_*, LD_PRELOAD (gamemoderun) and LD_LIBRARY_PATH. +SESSION_PREFIXES = ( + 'XDG_SESSION_', 'XDG_SEAT', 'XDG_VTNR', 'XDG_CURRENT_DESKTOP', 'XDG_MENU_PREFIX', + 'GNOME_', 'KDE_', 'GDM', 'SSH_', 'SYSTEMD_', 'MEMORY_PRESSURE_', 'QT_', 'GTK_', 'GDK_', + 'GIO_', 'GSETTINGS_', 'GJS_', 'FLATPAK_', 'SNAP_', 'KONSOLE_', 'TERM_PROGRAM', 'BASH_FUNC_', +) +SESSION_NAMES = { + 'WAYLAND_DISPLAY', 'DESKTOP_SESSION', 'DESKTOP_STARTUP_ID', 'SESSION_MANAGER', + 'INVOCATION_ID', 'JOURNAL_STREAM', 'MANAGERPID', 'MANAGERPIDFDID', 'NOTIFY_SOCKET', + 'LISTEN_FDS', 'LISTEN_PID', 'LISTEN_FDNAMES', 'COLORTERM', 'VTE_VERSION', 'WINDOWID', + 'WINDOWPATH', 'CHROME_DESKTOP', 'NO_AT_BRIDGE', 'MOTD_SHOWN', 'SNAP', 'APPIMAGE', 'APPDIR', + 'OWD', 'VIRTUAL_ENV', 'DEBUGINFOD_URLS', 'LS_COLORS', 'COLORFGBG', 'OLDPWD', 'SHLVL', +} +GUEST_SHELL = '/bin/zsh' + + +def host_session_variable(name): + return name in SESSION_NAMES or name.startswith(SESSION_PREFIXES) + + +def filter_host_environment(environ): + """Return the names removed from environ (a mutable mapping), honoring RCLOL_KEEP_HOST_ENV.""" + keep = set(environ.get('RCLOL_KEEP_HOST_ENV', '').replace(',', ' ').split()) + removed = sorted(name for name in environ if host_session_variable(name) and name not in keep) + for name in removed: + del environ[name] + # The host login shell (for example /bin/fish) does not exist in the guest. + if 'SHELL' not in keep: + environ['SHELL'] = GUEST_SHELL + return removed + def close_inherited_fds(): """A terminal, IDE or service manager can leak descriptors; the guest must not inherit them.""" @@ -22,6 +61,8 @@ def close_inherited_fds(): def main(argv): if len(argv) < 2: sys.exit('usage: exec-guest.py COMMAND [ARGUMENT]...') + if os.environ.get('RCLOL_FILTER_HOST_ENV') == '1': + filter_host_environment(os.environ) close_inherited_fds() os.execvp(argv[1], argv[1:]) diff --git a/tools/riot-launcher-offline-check.py b/tools/riot-launcher-offline-check.py index 46a8a09..0280d4e 100644 --- a/tools/riot-launcher-offline-check.py +++ b/tools/riot-launcher-offline-check.py @@ -36,7 +36,8 @@ if record["command"] == "darling": return False record["fds"] = sorted(fd for fd in map(int, os.listdir("/proc/self/fd")) if is_open(fd)) record["flags"] = {{key: os.environ.get(key) for key in - ("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY")}} + ("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY", + "WAYLAND_DISPLAY", "XAUTHORITY", "DRI_PRIME", "MESA_NO_ERROR", "__GL_THREADED_OPTIMIZATIONS", "SHELL")}} with open({str(log)!r}, "a") as output: output.write(json.dumps(record) + "\\n") ''' @@ -116,7 +117,17 @@ with open({str(log)!r}, "a") as output: subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True) clean = json.loads(log.read_text().splitlines()[-1]) assert clean["flags"]["DYLD_INSERT_LIBRARIES"] is None - print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent runtime=preserved inherited_fds=closed") + # The opt-in host environment filter drops session metadata but never the launcher's GPU tuning. + env.update({"RCLOL_FILTER_HOST_ENV": "1", "WAYLAND_DISPLAY": "wayland-0", "XAUTHORITY": "/tmp/xauth", + "DRI_PRIME": "1", "SHELL": "/bin/fish"}) + env.pop("MESA_NO_ERROR", None) + env.pop("__GL_THREADED_OPTIMIZATIONS", None) + subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True) + filtered = json.loads(log.read_text().splitlines()[-1])["flags"] + assert filtered["WAYLAND_DISPLAY"] is None and filtered["SHELL"] == "/bin/zsh", "host session metadata survived" + assert (filtered["XAUTHORITY"], filtered["DRI_PRIME"]) == ("/tmp/xauth", "1"), "X11/GPU selection lost" + assert (filtered["MESA_NO_ERROR"], filtered["__GL_THREADED_OPTIMIZATIONS"]) == ("1", "0"), "launcher GPU tuning lost" + print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent runtime=preserved inherited_fds=closed host_env_filter=PASS") if __name__ == "__main__":