steam-backlog-enforcer/steam_backlog_enforcer/_desktop_env.py
Krzysztof kuhy Rudnicki 21fe2f7742 Stop the enforcer launching Steam and notifications as root
The enforce service runs as root (no User=) but sets DISPLAY=:0, so any GUI
it starts lands on the desktop session. Two resolvers disagreed about who to
drop privileges to:

  library_hider._resolve_desktop_user()  read STEAM_ENFORCER_DESKTOP_USER
  game_install._get_real_user()          read only SUDO_USER or USER

systemd sets neither of the latter, so _get_real_user() returned None under
the service and every `geteuid() == 0 and real_user and ...` guard fell
through to running as root. Steam answered with a zenity modal ("Cannot run
as root user", /usr/lib/steam/steam:261) on screen.

Confirmed from the live process tree: root `bash /usr/lib/steam/steam
-cef-disable-gpu ... steam://install/618510` with a root zenity child. It
never reached the journal, because that text goes to zenity and _run_as_user
sends child output to DEVNULL.

- _desktop_env: add resolve_desktop_user/desktop_uid/desktop_user_cmd. Only
  the argv builder is shared, never the spawn call: tests/_no_subprocess.py
  patches subprocess per module by name, so a spawn site in an unpatched
  module could fire a real `sudo -u kuhy steam`.
- game_install._get_real_user: delegate to the shared resolver. This alone
  also fixes the root `steam -silent` in _ensure_steam_running and the
  no-op os.chown on new appmanifests.
- _trigger_steam_install / enforcer.send_notification: drop privileges.
  send_notification had no DBUS_SESSION_BUS_ADDRESS, so it silently reached
  nobody for all six of its callers.
- _run_as_user: fail closed. Running unchanged as root when no desktop user
  resolves is what put the modal on screen; refuse and log instead.

Also stop the retry storm behind it. steamapps does not exist (Steam has
never completed sign-in), so is_game_installed() was always False and the
loop re-triggered every allowed game every pass: 1656 attempts in 30
minutes, service RSS 1.5 GB (peak 4.3 GB), a FileNotFoundError traceback
each time. Guard install_game and _reinstall_missing_allowed on
steam_library_ready() -- a missing library means no install can succeed by
any route, so this is a correctness check rather than a tuned retry limit.

The module splits here are forced by the pre-commit 250-line gate, which
blocks any commit touching an over-cap file: game_install, library_hider and
_enforce_loop were already over before this change. They are split by
responsibility (_cdp transport, _steam_launch lifecycle, _steam_process
spawning, _steam_errors, _steam_client, _game_names, game_uninstall,
_enforce_steps, _owned_apps_cache, _echo, _protected_apps). game_install and
library_hider re-export their old public names, so the 15 modules that
import from them are untouched by this commit.

Verified as real root under the unit's environment: argv is now
`sudo -u kuhy env DISPLAY=:0 ... xdg-open steam://install/...` for both the
installer and notify-send; three enforce passes spawn no process and log
once; _run_as_user refuses with no desktop user. After restart: 0 retries,
0 tracebacks, 0 root-owned files under ~/.local/share/Steam, RSS 342 MB.

1311 tests pass. _desktop_env and _steam_state at 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
2026-08-24 13:55:44 +02:00

121 lines
4.6 KiB
Python

"""Environment handed to processes launched into the user's desktop session.
The enforcer runs as root under systemd, so anything it starts on the desktop
user's behalf goes through ``sudo -u <user> env ...``. ``sudo`` resets the
environment, which makes the list below an *allowlist*: every variable the
desktop session needs must be named here explicitly, and every omission fails
silently rather than erroring.
Lives in its own module (like ``_steam_state``) so both ``library_hider`` and
``game_install`` can share one builder without forming an import cycle.
``XDG_RUNTIME_DIR`` is load-bearing and was missing here for a long time.
Without it Wine cannot reach ``$XDG_RUNTIME_DIR/pulse/native``, silently falls
back from ``winepulse.drv`` to ``winealsa.drv``, and every Proton game gets raw
ALSA devices instead of the session's PulseAudio sink. That crashed Kingdom
Come: Deliverance II during its startup video, and looked like a corrupt
install because restarting Steam by hand - the thing a reinstall incidentally
does - was what actually cleared it.
Only ``XDG_RUNTIME_DIR`` is exported for audio, deliberately: pressure-vessel
reads it to bind-mount the pulse socket into the Steam Linux Runtime container,
whereas a hardcoded ``PULSE_SERVER=unix:/run/user/<uid>/pulse/native`` would
name a *host* path that need not resolve inside that container.
"""
from __future__ import annotations
import os
from pathlib import Path
import pwd
def desktop_runtime_dir(uid: int) -> str:
"""Path to the desktop user's XDG runtime directory."""
return f"/run/user/{uid}"
def desktop_session_ready(uid: int) -> bool:
"""Whether the desktop user's runtime directory exists yet.
The enforcer is a system service ordered only ``After=network-online``,
while ``/run/user/<uid>`` is created by ``user-runtime-dir@<uid>.service``
when logind opens the session. On a cold boot the enforcer can reach its
first launch before that directory exists — measured on one boot, it
started 54ms *before* the runtime-dir unit and only won by doing other
work first.
A Steam launched in that window would name a runtime dir that is not
there, which is exactly the winealsa fallback this module exists to
prevent, and it would stay broken for the whole session. Callers gate on
this and retry on the next enforce pass instead.
"""
return Path(desktop_runtime_dir(uid)).is_dir()
def desktop_env_args(user: str, uid: int) -> list[str]:
"""Build the ``env`` arguments for a command run as the desktop *user*.
Session paths are derived from *uid* rather than read from the current
environment: the enforcer normally runs as root, and inheriting root's
``XDG_RUNTIME_DIR`` would point the child at ``/run/user/0``, which it
cannot use.
"""
runtime_dir = desktop_runtime_dir(uid)
dbus_default = f"unix:path={runtime_dir}/bus"
dbus_addr = os.environ.get("DBUS_SESSION_BUS_ADDRESS", dbus_default)
xauth = os.environ.get("XAUTHORITY", f"/home/{user}/.Xauthority")
return [
f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
f"XAUTHORITY={xauth}",
f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}",
f"XDG_RUNTIME_DIR={runtime_dir}",
]
def desktop_uid(user: str | None) -> int:
"""Resolve *user* to a uid, defaulting to the usual first desktop uid."""
if user:
try:
return pwd.getpwnam(user).pw_uid
except KeyError:
pass
return 1000
def resolve_desktop_user() -> str | None:
"""Resolve which desktop user owns the Steam/X11 session.
Prefers the explicit STEAM_ENFORCER_DESKTOP_USER (set by the systemd
unit, which has no SUDO_USER/USER of its own since it is started
directly by systemd rather than via `sudo`), then falls back to
SUDO_USER/USER for interactive `sudo` invocations.
"""
return (
os.environ.get("STEAM_ENFORCER_DESKTOP_USER")
or os.environ.get("SUDO_USER")
or os.environ.get("USER")
)
def desktop_user_cmd(cmd: list[str], user: str | None) -> list[str]:
"""Wrap *cmd* so it runs as *user* when the caller is root.
Returns *cmd* unchanged when already unprivileged or when no desktop user
resolves; otherwise prefixes ``sudo -u <user> env <session vars>``.
This is argv construction only — deliberately no ``subprocess`` call, so
every spawn site stays in a module covered by the test suite's
``_no_subprocess`` guard.
"""
if os.geteuid() == 0 and user and user != "root":
return [
"sudo",
"-u",
user,
"env",
*desktop_env_args(user, desktop_uid(user)),
*cmd,
]
return cmd