fix(process_registry): stop systemd-run expanding scoped commands (#132385)

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.
This commit is contained in:
kshitijk4poor
2026-10-04 00:58:02 +05:30
committed by kshitij
parent ea81748579
commit 9cf7960f27
2 changed files with 48 additions and 9 deletions

View File

@@ -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."""

View File

@@ -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(