mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
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
121 lines
4.6 KiB
Python
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
|