From 9cf7960f274ea2bdfe672d64d26389e9954dfeb6 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:58:02 +0530 Subject: [PATCH] fix(process_registry): stop systemd-run expanding scoped commands (#132385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since systemd 254, `systemd-run --scope` expands $VAR / ${VAR} / $$ in the command line itself (--expand-environment defaults to yes), so a background command under a supervised gateway ran rewritten: `$$` became a literal `$`, `${arr[@]}` became '' with a warning on stderr. _systemd_scope_argv is the single argv choke point shared by the availability probe, the pipe/PTY background spawns and restart-safe cron/kanban children, so pass `--expand-environment=no` there. systemd-run < 254 rejects the option (and never expanded in scope mode), so the existing cached probe drops it and re-probes once when systemd-run names it in the rejection — no extra subprocess on modern hosts and no lost isolation on old ones. Fixes #132385. --- tests/tools/test_process_registry.py | 28 +++++++++++++++++++++++++++ tools/process_registry.py | 29 +++++++++++++++++++--------- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 8faa5cf9a3..3c4ff4c69c 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -2635,6 +2635,34 @@ class TestSystemdCgroupIsolation: value.startswith("OOMPolicy=") for value in probe_argv if isinstance(value, str) ), probe_argv + @pytest.mark.platforms("linux") + @pytest.mark.parametrize("rejects_no_expand", [False, True], ids=["systemd>=254", "systemd<254"]) + def test_scoped_command_is_not_expanded_by_systemd_run(self, monkeypatch, rejects_no_expand): + """systemd >= 254 rewrites ``$$``/``${X}`` in a --scope command unless given + ``--expand-environment=no``; older systemd-run rejects that option, and losing + it there must not cost the scope (#132385).""" + import tools.process_registry as pr + + monkeypatch.setattr(pr, "_SYSTEMD_SCOPE_AVAILABLE", None) + monkeypatch.setattr(pr, "_SYSTEMD_RUN_NO_EXPAND", True) + probes = [] + + def fake_run(argv, **kwargs): + probes.append(argv) + if rejects_no_expand and "--expand-environment=no" in argv: + return subprocess.CompletedProcess( + argv, 1, stderr=b"systemd-run: unrecognized option '--expand-environment=no'") + return subprocess.CompletedProcess(argv, 0, stderr=b"") + + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run") + monkeypatch.setattr("subprocess.run", fake_run) + + assert pr._systemd_run_user_scope_available() is True + spawn = pr._build_systemd_scope_argv(["bash", "-c", "echo $$"], unit_suffix="t") + assert ("--expand-environment=no" in spawn) is not rejects_no_expand + assert spawn[spawn.index("--") + 1:] == ["bash", "-c", "echo $$"] + assert len(probes) == (2 if rejects_no_expand else 1) + @pytest.mark.platforms("linux") def test_successful_systemd_probe_revalidates_after_cache_ttl(self, monkeypatch): """A vanished user bus invalidates a formerly successful scope verdict.""" diff --git a/tools/process_registry.py b/tools/process_registry.py index 80b5173da2..d5bdd5e659 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -105,6 +105,9 @@ _SYSTEMD_SCOPE_PROBED_AT = 0.0 # Both verdicts expire: the user bus can vanish after a True (session logout without linger, # #110803) and reappear after a False (linger enabled later, #104893). _SYSTEMD_SCOPE_PROBE_TTL_SECONDS = 60.0 +# systemd >= 254 expands ``$$``/``${X}`` in a ``--scope`` command line itself unless told not to; +# older systemd-run rejects the option (and never expanded there), so the probe drops it on rejection. +_SYSTEMD_RUN_NO_EXPAND = True _MIN_WORKER_MEMORY_MAX_BYTES = 64 * 1024 * 1024 _DEFAULT_WORKER_MEMORY_MAX_BYTES = 1024 * 1024 * 1024 _WORKER_MEMORY_MAX_CAP_BYTES = 4 * 1024 * 1024 * 1024 @@ -167,9 +170,11 @@ def _worker_memory_max_bytes() -> int: def _systemd_scope_argv(binary: str, unit_name: str, *argv: str) -> List[str]: """``systemd-run --user --scope`` argv shared by the probe and real spawns. ``--collect`` self-cleans the scope after exit; ``--unit`` names it for systemctl. - No ``OOMPolicy=``: transient scopes reject it on systemd <253 (#102486).""" + No ``OOMPolicy=``: transient scopes reject it on systemd <253 (#102486). + ``--expand-environment=no`` keeps the command byte-identical (#132385).""" + no_expand = ["--expand-environment=no"] if _SYSTEMD_RUN_NO_EXPAND else [] return [ - binary, "--user", "--scope", "--quiet", "--unit", unit_name, "--collect", + binary, "--user", "--scope", "--quiet", *no_expand, "--unit", unit_name, "--collect", "--property", "MemoryAccounting=yes", "--property", f"MemoryMax={_worker_memory_max_bytes()}", "--", *argv, @@ -248,7 +253,7 @@ def _systemd_run_user_scope_available() -> bool: Use ``/bin/sh -c 'exit 0'``: NixOS provides ``/bin/sh`` but not ``/bin/true`` (#105365), regardless of the gateway service's PATH.""" - global _SYSTEMD_SCOPE_AVAILABLE, _SYSTEMD_SCOPE_PROBED_AT + global _SYSTEMD_SCOPE_AVAILABLE, _SYSTEMD_SCOPE_PROBED_AT, _SYSTEMD_RUN_NO_EXPAND verdict = _systemd_scope_cached() if verdict is not None: return verdict @@ -267,12 +272,18 @@ def _systemd_run_user_scope_available() -> bool: if binary: # Unique unit avoids collisions; the timeout bounds D-Bus. probe_unit = f"hermes-probe-scope-{os.getpid()}-{uuid.uuid4().hex[:8]}" - result = subprocess.run( - _systemd_scope_argv(binary, probe_unit, "/bin/sh", "-c", "exit 0"), - capture_output=True, - timeout=3, - env=systemd_user_bus_env(), - ) + for _attempt in range(2): + result = subprocess.run( + _systemd_scope_argv(binary, probe_unit, "/bin/sh", "-c", "exit 0"), + capture_output=True, + timeout=3, + env=systemd_user_bus_env(), + ) + if not (result.returncode and _SYSTEMD_RUN_NO_EXPAND + and b"expand-environment" in (result.stderr or b"")): + break + # systemd < 254 rejects the option: drop it and probe again. + _SYSTEMD_RUN_NO_EXPAND = False available = result.returncode == 0 if not available: logger.debug(