personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Recover desktop session env vars from systemd when missing

When the observer is launched from SSH or tmux, DISPLAY,
WAYLAND_DISPLAY, and DBUS_SESSION_BUS_ADDRESS may be missing from the
inherited environment. Before failing with EX_TEMPFAIL, query
`systemctl --user show-environment` to recover these vars from the
running GNOME session (which pushes them into the systemd user manager
on startup). Silently falls back to existing behavior if systemctl is
unavailable or the session isn't running.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+228
+44
observe/linux/observer.py
··· 829 829 logger.info("Callosum connection stopped") 830 830 831 831 832 + def _recover_session_env() -> None: 833 + """Try to recover desktop session env vars from the systemd user manager. 834 + 835 + On GNOME Wayland, gnome-shell pushes DISPLAY, WAYLAND_DISPLAY, and 836 + DBUS_SESSION_BUS_ADDRESS into the systemd user environment on startup. 837 + When the observer is launched from SSH or tmux, these vars may be missing 838 + from the inherited environment — but systemctl --user show-environment 839 + has them. 840 + """ 841 + needed = {"DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS"} 842 + missing = {v for v in needed if not os.environ.get(v)} 843 + if not missing: 844 + return 845 + 846 + # Ensure XDG_RUNTIME_DIR is set (required for systemctl --user to connect) 847 + if not os.environ.get("XDG_RUNTIME_DIR"): 848 + os.environ["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}" 849 + 850 + try: 851 + result = subprocess.run( 852 + ["systemctl", "--user", "show-environment"], 853 + capture_output=True, 854 + text=True, 855 + timeout=5, 856 + ) 857 + if result.returncode != 0: 858 + return 859 + except (FileNotFoundError, subprocess.TimeoutExpired): 860 + return 861 + 862 + recovered = [] 863 + for line in result.stdout.splitlines(): 864 + key, _, value = line.partition("=") 865 + if key in missing and value: 866 + os.environ[key] = value 867 + recovered.append(f"{key}={value}") 868 + 869 + if recovered: 870 + logger.info("Recovered session env from systemd: %s", ", ".join(recovered)) 871 + 872 + 832 873 def check_session_ready() -> str | None: 833 874 """Check if the desktop session is ready for observation. 834 875 835 876 Returns None if ready, or a description of what's missing. 836 877 """ 878 + # Try to recover missing session vars from systemd user manager 879 + _recover_session_env() 880 + 837 881 # Display server 838 882 if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): 839 883 return "no display server (DISPLAY/WAYLAND_DISPLAY not set)"
+184
tests/test_session_env.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for desktop session environment recovery.""" 5 + 6 + import os 7 + import subprocess 8 + from unittest.mock import patch 9 + 10 + from observe.linux.observer import _recover_session_env, check_session_ready 11 + 12 + 13 + class TestRecoverSessionEnv: 14 + """Tests for _recover_session_env().""" 15 + 16 + def test_noop_when_vars_already_set(self, monkeypatch): 17 + """Should not call systemctl when all vars are present.""" 18 + monkeypatch.setenv("DISPLAY", ":1") 19 + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") 20 + monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus") 21 + 22 + with patch("observe.linux.observer.subprocess.run") as mock_run: 23 + _recover_session_env() 24 + mock_run.assert_not_called() 25 + 26 + def test_recovers_missing_vars(self, monkeypatch): 27 + """Should recover missing vars from systemctl output.""" 28 + monkeypatch.delenv("DISPLAY", raising=False) 29 + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) 30 + monkeypatch.delenv("DBUS_SESSION_BUS_ADDRESS", raising=False) 31 + monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/user/1000") 32 + 33 + systemctl_output = ( 34 + "HOME=/home/user\n" 35 + "DISPLAY=:0\n" 36 + "WAYLAND_DISPLAY=wayland-0\n" 37 + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus\n" 38 + "XDG_SESSION_TYPE=wayland\n" 39 + ) 40 + mock_result = subprocess.CompletedProcess( 41 + args=[], returncode=0, stdout=systemctl_output, stderr="" 42 + ) 43 + with patch("observe.linux.observer.subprocess.run", return_value=mock_result): 44 + _recover_session_env() 45 + 46 + assert os.environ.get("DISPLAY") == ":0" 47 + assert os.environ.get("WAYLAND_DISPLAY") == "wayland-0" 48 + assert ( 49 + os.environ.get("DBUS_SESSION_BUS_ADDRESS") == "unix:path=/run/user/1000/bus" 50 + ) 51 + 52 + def test_recovers_only_missing_vars(self, monkeypatch): 53 + """Should not overwrite vars that are already set.""" 54 + monkeypatch.setenv("DISPLAY", ":5") 55 + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) 56 + monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus") 57 + monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/user/1000") 58 + 59 + systemctl_output = "DISPLAY=:0\nWAYLAND_DISPLAY=wayland-0\n" 60 + mock_result = subprocess.CompletedProcess( 61 + args=[], returncode=0, stdout=systemctl_output, stderr="" 62 + ) 63 + with patch("observe.linux.observer.subprocess.run", return_value=mock_result): 64 + _recover_session_env() 65 + 66 + assert os.environ.get("DISPLAY") == ":5" # unchanged 67 + assert os.environ.get("WAYLAND_DISPLAY") == "wayland-0" # recovered 68 + 69 + def test_sets_xdg_runtime_dir_if_missing(self, monkeypatch): 70 + """Should set XDG_RUNTIME_DIR from uid when missing.""" 71 + monkeypatch.delenv("DISPLAY", raising=False) 72 + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) 73 + monkeypatch.delenv("DBUS_SESSION_BUS_ADDRESS", raising=False) 74 + monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False) 75 + 76 + mock_result = subprocess.CompletedProcess( 77 + args=[], returncode=0, stdout="DISPLAY=:0\n", stderr="" 78 + ) 79 + with patch("observe.linux.observer.subprocess.run", return_value=mock_result): 80 + _recover_session_env() 81 + 82 + assert os.environ.get("XDG_RUNTIME_DIR") == f"/run/user/{os.getuid()}" 83 + 84 + def test_handles_systemctl_failure(self, monkeypatch): 85 + """Should silently handle systemctl failure.""" 86 + monkeypatch.delenv("DISPLAY", raising=False) 87 + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) 88 + monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/user/1000") 89 + 90 + mock_result = subprocess.CompletedProcess( 91 + args=[], returncode=1, stdout="", stderr="error" 92 + ) 93 + with patch("observe.linux.observer.subprocess.run", return_value=mock_result): 94 + _recover_session_env() 95 + 96 + assert not os.environ.get("DISPLAY") 97 + 98 + def test_handles_systemctl_not_found(self, monkeypatch): 99 + """Should silently handle missing systemctl binary.""" 100 + monkeypatch.delenv("DISPLAY", raising=False) 101 + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) 102 + monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/user/1000") 103 + 104 + with patch( 105 + "observe.linux.observer.subprocess.run", side_effect=FileNotFoundError 106 + ): 107 + _recover_session_env() 108 + 109 + assert not os.environ.get("DISPLAY") 110 + 111 + def test_handles_systemctl_timeout(self, monkeypatch): 112 + """Should silently handle systemctl timeout.""" 113 + monkeypatch.delenv("DISPLAY", raising=False) 114 + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) 115 + monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/user/1000") 116 + 117 + with patch( 118 + "observe.linux.observer.subprocess.run", 119 + side_effect=subprocess.TimeoutExpired(cmd="systemctl", timeout=5), 120 + ): 121 + _recover_session_env() 122 + 123 + assert not os.environ.get("DISPLAY") 124 + 125 + def test_ignores_empty_values(self, monkeypatch): 126 + """Should not set vars with empty values.""" 127 + monkeypatch.delenv("DISPLAY", raising=False) 128 + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) 129 + monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus") 130 + monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/user/1000") 131 + 132 + systemctl_output = "DISPLAY=\nWAYLAND_DISPLAY=wayland-0\n" 133 + mock_result = subprocess.CompletedProcess( 134 + args=[], returncode=0, stdout=systemctl_output, stderr="" 135 + ) 136 + with patch("observe.linux.observer.subprocess.run", return_value=mock_result): 137 + _recover_session_env() 138 + 139 + assert not os.environ.get("DISPLAY") 140 + assert os.environ.get("WAYLAND_DISPLAY") == "wayland-0" 141 + 142 + 143 + class TestCheckSessionReady: 144 + """Tests for check_session_ready() with env recovery integration.""" 145 + 146 + def test_ready_after_recovery(self, monkeypatch): 147 + """Should pass after recovering vars from systemd.""" 148 + monkeypatch.delenv("DISPLAY", raising=False) 149 + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) 150 + monkeypatch.delenv("DBUS_SESSION_BUS_ADDRESS", raising=False) 151 + monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/user/1000") 152 + 153 + systemctl_output = ( 154 + "DISPLAY=:0\n" 155 + "WAYLAND_DISPLAY=wayland-0\n" 156 + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus\n" 157 + ) 158 + mock_result = subprocess.CompletedProcess( 159 + args=[], returncode=0, stdout=systemctl_output, stderr="" 160 + ) 161 + with ( 162 + patch("observe.linux.observer.subprocess.run", return_value=mock_result), 163 + patch("observe.linux.observer.shutil.which", return_value=None), 164 + ): 165 + result = check_session_ready() 166 + 167 + assert result is None 168 + 169 + def test_fails_when_recovery_incomplete(self, monkeypatch): 170 + """Should fail when recovery doesn't provide display vars.""" 171 + monkeypatch.delenv("DISPLAY", raising=False) 172 + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) 173 + monkeypatch.delenv("DBUS_SESSION_BUS_ADDRESS", raising=False) 174 + monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/user/1000") 175 + 176 + # systemctl returns nothing useful 177 + mock_result = subprocess.CompletedProcess( 178 + args=[], returncode=0, stdout="HOME=/home/user\n", stderr="" 179 + ) 180 + with patch("observe.linux.observer.subprocess.run", return_value=mock_result): 181 + result = check_session_ready() 182 + 183 + assert result is not None 184 + assert "DISPLAY" in result