Harden Panama doctor probes

This commit is contained in:
Gabriel Brown
2026-08-18 09:00:35 -04:00
parent d58c431199
commit 0ca83f74c7
6 changed files with 147 additions and 18 deletions
+100 -17
View File
@@ -54,20 +54,22 @@ class DoctorConfig:
@property @property
def command_env(self) -> dict[str, str]: def command_env(self) -> dict[str, str]:
environment = dict(os.environ) environment = {
environment["PATH"] = self.path "PATH": self.path,
environment["HOME"] = str(self.home) "HOME": str(self.home),
environment["XDG_CONFIG_HOME"] = str(self.config_home) "XDG_CONFIG_HOME": str(self.config_home),
environment["XDG_STATE_HOME"] = str(self.state_home) "XDG_STATE_HOME": str(self.state_home),
environment["XDG_RUNTIME_DIR"] = str(self.runtime_dir) "XDG_RUNTIME_DIR": str(self.runtime_dir),
environment.pop("PANAMA_HOME_ASSISTANT_URL", None) }
environment.pop("PANAMA_HOME_ASSISTANT_TOKEN", None) for name in PROBE_ENVIRONMENT_KEYS:
if value := os.environ.get(name):
environment[name] = value
return environment return environment
@dataclass(frozen=True) @dataclass(frozen=True)
class CommandResult: class CommandResult:
state: Literal["ok", "missing", "timeout", "failed"] state: Literal["ok", "missing", "timeout", "failed", "unavailable"]
stdout: str = "" stdout: str = ""
@@ -91,6 +93,52 @@ SYSTEMCTL_COMMANDS = {
PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle") PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle")
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b") VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE) REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
PROBE_ENVIRONMENT_KEYS = (
"LANG",
"LC_ALL",
"LC_CTYPE",
"TZ",
"DBUS_SESSION_BUS_ADDRESS",
"WAYLAND_DISPLAY",
"DISPLAY",
"XAUTHORITY",
"PANAMA_DOCTOR_FIXTURE_STOPPED",
"PANAMA_DOCTOR_FIXTURE_PROCESSES",
"PANAMA_DOCTOR_FIXTURE_BUS",
"PANAMA_DOCTOR_FIXTURE_QS",
"PANAMA_DOCTOR_FIXTURE_QS_VERSION",
"PANAMA_DOCTOR_FIXTURE_BLUEBUBBLES",
"PANAMA_DOCTOR_FIXTURE_CALENDAR",
"PANAMA_DOCTOR_FIXTURE_BRIGHTNESS",
"PANAMA_DOCTOR_FIXTURE_CAFFEINE",
)
CHECK_TITLES = {
"desktop.hyprland": "Hyprland",
"desktop.quickshell": "Quickshell",
"desktop.notifications": "Notifications",
"desktop.portals": "Desktop portals",
"desktop.hyprpaper": "Hyprpaper",
"desktop.hypridle": "Hypridle",
"desktop.vicinae": "Vicinae",
"input.pipewire": "PipeWire",
"input.clipboard": "Clipboard",
"input.wallpaper": "Wallpaper",
"input.capture": "Capture",
"input.ocr": "OCR",
"input.brightness": "External monitor brightness",
"integration.nextcloud": "Nextcloud",
"integration.rustdesk": "RustDesk",
"integration.kdeconnect": "KDE Connect",
"integration.bluebubbles": "BlueBubbles",
"integration.home-assistant": "Home Assistant",
"integration.calendar": "Calendar",
"panama.runtime-links": "Panama runtime links",
"panama.vicinae-commands": "Panama commands",
"panama.selected-terminal": "Selected terminal",
"panama.selected-launcher": "Selected launcher",
"panama.processes": "Panama processes",
"panama.caffeine": "Caffeine inhibitor",
}
def environment_path(name: str, default: Path) -> Path: def environment_path(name: str, default: Path) -> Path:
@@ -119,6 +167,8 @@ def run_command(command: tuple[str, ...], config: DoctorConfig, cwd: Path | None
return CommandResult("missing") return CommandResult("missing")
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return CommandResult("timeout") return CommandResult("timeout")
except OSError:
return CommandResult("unavailable")
if completed.returncode != 0: if completed.returncode != 0:
return CommandResult("failed") return CommandResult("failed")
return CommandResult("ok", completed.stdout) return CommandResult("ok", completed.stdout)
@@ -156,7 +206,7 @@ def service_check(check_id: str, title: str, service: str, config: DoctorConfig,
result = run_command(SYSTEMCTL_COMMANDS[service], config) result = run_command(SYSTEMCTL_COMMANDS[service], config)
if result.state == "ok": if result.state == "ok":
return Check(check_id, group_for(check_id), title, "ok", "Service is active.") return Check(check_id, group_for(check_id), title, "ok", "Service is active.")
if result.state == "missing": if result.state in {"missing", "unavailable"}:
return Check(check_id, group_for(check_id), title, "error", "Required system service probe is unavailable.") return Check(check_id, group_for(check_id), title, "error", "Required system service probe is unavailable.")
return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action) return Check(check_id, group_for(check_id), title, "warning", "Service is not active.", action)
@@ -315,7 +365,10 @@ def check_processes(config: DoctorConfig) -> Check:
for name in PROCESS_NAMES: for name in PROCESS_NAMES:
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config) result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
if result.state == "ok": if result.state == "ok":
counts.append(sum(line.isdecimal() for line in result.stdout.splitlines())) pids = result.stdout.splitlines()
if not pids or any(not pid.isdecimal() for pid in pids):
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe returned an invalid result.")
counts.append(len(pids))
elif result.state == "failed": elif result.state == "failed":
counts.append(0) counts.append(0)
else: else:
@@ -332,10 +385,19 @@ def check_caffeine(config: DoctorConfig) -> Check:
if result.state != "ok": if result.state != "ok":
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.") return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.")
uid = str(os.getuid()) uid = str(os.getuid())
inhibitors = sum( inhibitors = 0
1 for line in result.stdout.splitlines() malformed = False
if (parts := line.split()) and len(parts) >= 8 and parts[0] == "Panama" and parts[1] == uid and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"] for line in result.stdout.splitlines():
) parts = line.split()
relevant = len(parts) >= 2 and parts[0] == "Panama" and parts[1] == uid and "Caffeine" in parts
if not relevant:
continue
if len(parts) >= 8 and parts[3].isdecimal() and parts[-2:] == ["Caffeine", "block"]:
inhibitors += 1
else:
malformed = True
if malformed:
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.")
if inhibitors > 1: if inhibitors > 1:
return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors")) return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors"))
if inhibitors == 1: if inhibitors == 1:
@@ -362,6 +424,14 @@ def context_versions(config: DoctorConfig) -> list[dict[str, str]]:
return [{"id": "hyprland", "version": parse_version(hyprland)}, {"id": "quickshell", "version": parse_version(quickshell)}, {"id": "fedora", "version": fedora}, {"id": "panama", "version": parse_version(revision, REVISION_PATTERN)}] return [{"id": "hyprland", "version": parse_version(hyprland)}, {"id": "quickshell", "version": parse_version(quickshell)}, {"id": "fedora", "version": fedora}, {"id": "panama", "version": parse_version(revision, REVISION_PATTERN)}]
def unavailable_check(check_id: str) -> Check:
return Check(check_id, group_for(check_id), CHECK_TITLES[check_id], "warning", "Diagnostic probe could not be completed.")
def unavailable_versions() -> list[dict[str, str]]:
return [{"id": name, "version": "unavailable"} for name in ("hyprland", "quickshell", "fedora", "panama")]
def collect_checks(config: DoctorConfig) -> list[Check]: def collect_checks(config: DoctorConfig) -> list[Check]:
probes: dict[str, Callable[[], Check]] = { probes: dict[str, Callable[[], Check]] = {
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config), "desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config),
@@ -372,15 +442,28 @@ def collect_checks(config: DoctorConfig) -> list[Check]:
} }
with ThreadPoolExecutor(max_workers=8) as executor: with ThreadPoolExecutor(max_workers=8) as executor:
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER} futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
return [futures[check_id].result() for check_id in CHECK_ORDER] checks: list[Check] = []
for check_id in CHECK_ORDER:
try:
checks.append(futures[check_id].result())
except Exception:
checks.append(unavailable_check(check_id))
return checks
def snapshot(config: DoctorConfig) -> dict[str, object]: def snapshot(config: DoctorConfig) -> dict[str, object]:
try:
checks = collect_checks(config) checks = collect_checks(config)
except Exception:
checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")} counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")}
overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy" overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy"
session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other" session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other"
return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": context_versions(config)}, "checks": [check_json(check) for check in checks]} try:
versions = context_versions(config)
except Exception:
versions = unavailable_versions()
return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": versions}, "checks": [check_json(check) for check in checks]}
def main(argv: list[str]) -> int: def main(argv: list[str]) -> int:
@@ -5,6 +5,10 @@ set -euo pipefail
if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "missing" ]]; then if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "missing" ]]; then
exit 1 exit 1
fi fi
if [[ "${PANAMA_DOCTOR_FIXTURE_BUS:-ready}" == "invalid-utf8" ]]; then
printf '\377\n'
exit 0
fi
printf '%s\n' \ printf '%s\n' \
'org.freedesktop.portal.Desktop 1000 portal' \ 'org.freedesktop.portal.Desktop 1000 portal' \
'org.kde.kdeconnect 1000 kdeconnect' \ 'org.kde.kdeconnect 1000 kdeconnect' \
@@ -5,6 +5,7 @@ set -euo pipefail
name="${!#}" name="${!#}"
case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in
*",$name:duplicate,"*) printf '4101\n4102\n' ;; *",$name:duplicate,"*) printf '4101\n4102\n' ;;
*",$name:malformed,"*) printf 'not-a-pid\n' ;;
*",$name:missing,"*) exit 1 ;; *",$name:missing,"*) exit 1 ;;
*) printf '4101\n' ;; *) printf '4101\n' ;;
esac esac
@@ -2,6 +2,10 @@
set -euo pipefail set -euo pipefail
if [[ -n ${PANAMA_DOCTOR_FIXTURE_PROBE_SECRET+x} ]]; then
exit 97
fi
service="${4:-}" service="${4:-}"
case ",${PANAMA_DOCTOR_FIXTURE_STOPPED:-}," in case ",${PANAMA_DOCTOR_FIXTURE_STOPPED:-}," in
*",$service,"*) exit 3 ;; *",$service,"*) exit 3 ;;
@@ -7,4 +7,7 @@ printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n'
if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "duplicate" ]]; then if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "duplicate" ]]; then
printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid" printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid"
fi fi
if [[ "${PANAMA_DOCTOR_FIXTURE_CAFFEINE:-single}" == "malformed" ]]; then
printf 'Panama %s fixture-user invalid Caffeine\n' "$uid"
fi
printf 'Other %s fixture-secret-token AA:BB:CC:DD:EE:FF fixture clipboard body ignore ignore\n' "$uid" printf 'Other %s fixture-secret-token AA:BB:CC:DD:EE:FF fixture clipboard body ignore ignore\n' "$uid"
@@ -134,6 +134,22 @@ assert_schema_and_redaction "$snapshot"
# A healthy systemd-backed service stays healthy. # A healthy systemd-backed service stays healthy.
check_status "$snapshot" desktop.hyprpaper ok check_status "$snapshot" desktop.hyprpaper ok
# Arbitrary parent environment values are not propagated into probes.
sealed_environment="$(PANAMA_DOCTOR_FIXTURE_PROBE_SECRET=fixture-secret-token run_doctor --json)"
assert_schema_and_redaction "$sealed_environment"
check_status "$sealed_environment" desktop.hyprpaper ok
# An OS-level launch failure is contained as a check result, never a failed
# doctor invocation or a partial snapshot.
chmod 0644 "$bin_dir/systemctl"
if ! launch_failure="$(run_doctor --json)"; then
chmod +x "$bin_dir/systemctl"
fail 'launch failure prevented the doctor from emitting JSON'
fi
chmod +x "$bin_dir/systemctl"
assert_schema_and_redaction "$launch_failure"
check_status "$launch_failure" desktop.hyprpaper error
# A missing required executable is an error rather than a crash. # A missing required executable is an error rather than a crash.
mv "$bin_dir/qs" "$bin_dir/qs.off" mv "$bin_dir/qs" "$bin_dir/qs.off"
missing_qs="$(run_doctor --json)" missing_qs="$(run_doctor --json)"
@@ -183,6 +199,24 @@ check_status "$duplicated_processes" panama.processes warning
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \ ! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|| fail 'process detail exposed a PID' || fail 'process detail exposed a PID'
# Invalid output for a non-Quickshell authored process is not a normal zero
# count that can be hidden by the running Quickshell process.
malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)"
check_status "$malformed_processes" panama.processes warning
# Panama/Caffeine-shaped rows that do not satisfy the fixed inhibitor schema
# are unavailable rather than reported as a healthy no-inhibitor state.
malformed_caffeine="$(PANAMA_DOCTOR_FIXTURE_CAFFEINE=malformed run_doctor --json)"
check_status "$malformed_caffeine" panama.caffeine warning
# A decoding error raised inside a concurrent probe is converted to a complete
# snapshot rather than escaping from Future.result().
if ! invalid_probe="$(PANAMA_DOCTOR_FIXTURE_BUS=invalid-utf8 run_doctor --json)"; then
fail 'unexpected probe exception prevented the doctor from emitting JSON'
fi
assert_schema_and_redaction "$invalid_probe"
check_status "$invalid_probe" desktop.portals warning
# Configured Home Assistant failures route to the exact authored Settings page. # Configured Home Assistant failures route to the exact authored Settings page.
rm "$config_home/quickshell" rm "$config_home/quickshell"
mkdir -p "$config_home/quickshell/scripts" mkdir -p "$config_home/quickshell/scripts"