From 8bfae70284804ff570793d8ac2ceb346097954c0 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 10:58:48 -0400 Subject: [PATCH 1/5] Harden bounded Panama recovery actions --- config/dot/quickshell/scripts/panama-doctor | 150 +++++++++---- config/dot/quickshell/services/Health.qml | 4 + tests/quickshell/health-service-contract.sh | 90 +++++++- tests/quickshell/panama-doctor-contract.sh | 230 +++++++++++++++++--- 4 files changed, 403 insertions(+), 71 deletions(-) diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor index ccbf08f..2e8fa03 100755 --- a/config/dot/quickshell/scripts/panama-doctor +++ b/config/dot/quickshell/scripts/panama-doctor @@ -8,6 +8,8 @@ import argparse import json import os import re +import secrets +import signal import shutil import subprocess import sys @@ -409,8 +411,13 @@ def check_runtime_links(config: DoctorConfig) -> Check: def check_vicinae_commands(config: DoctorConfig) -> Check: source = config.root / "config/local/share/vicinae/scripts" - installed = config.home / ".local/share/vicinae/scripts" - if source.is_dir() and installed.is_symlink() and installed.exists(): + installed = config.home / ".local/share/vicinae/scripts/panama" + try: + linked = source.is_dir() and installed.is_symlink() \ + and installed.resolve(strict=False) == source.resolve(strict=True) + except OSError: + linked = False + if linked: return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "ok", "Panama Vicinae commands are linked.") return Check("panama.vicinae-commands", "panama-tools", "Panama commands", "warning", "Panama Vicinae commands are not linked.", Action("repair", "Repair command link")) @@ -445,20 +452,10 @@ def check_caffeine(config: DoctorConfig) -> Check: result = run_command(("systemd-inhibit", "--list", "--no-pager", "--no-legend"), config) if result.state != "ok": return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.") - uid = str(os.getuid()) - inhibitors = 0 - malformed = False - 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: + inhibitor_pids = parse_caffeine_pids(result.stdout, str(os.getuid())) + if inhibitor_pids is None: return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.") + inhibitors = len(dict.fromkeys(inhibitor_pids)) if inhibitors > 1: return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors")) if inhibitors == 1: @@ -535,10 +532,47 @@ def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult return RepairResult(check_id, True, exit_code, message) +def lexical_path(path: Path) -> Path: + """Normalize dot segments without following any filesystem symlink.""" + return Path(os.path.abspath(os.fspath(path))) + + +def lexical_link_target(destination: Path) -> Path: + target = Path(os.readlink(destination)) + return lexical_path(target if target.is_absolute() else destination.parent / target) + + +def atomic_symlink_replace(destination: Path, source: Path) -> None: + """Install an authored sibling symlink without first removing destination.""" + for _ in range(32): + temporary = destination.with_name( + f".panama-link-{destination.name}-{os.getpid()}-{secrets.token_hex(8)}" + ) + created = False + try: + os.symlink(source, temporary, target_is_directory=True) + created = True + os.replace(temporary, destination) + return + except FileExistsError: + continue + finally: + if created: + try: + temporary.unlink() + except FileNotFoundError: + pass + raise OSError("Could not allocate an authored temporary link") + + def repair_runtime_links(config: DoctorConfig) -> RepairResult: - sources = [(name, config.root / relative_source) for name, relative_source in RUNTIME_LINK_TARGETS] + root = lexical_path(config.root) + sources = [(name, lexical_path(config.root / relative_source)) for name, relative_source in RUNTIME_LINK_TARGETS] if any(not source.is_dir() for _, source in sources): return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are unavailable.") + if any(not source.is_relative_to(root) for _, source in sources): + return RepairResult("panama.runtime-links", True, 1, "Tracked Panama link destinations are invalid.") + authored_sources = frozenset(source for _, source in sources) try: config.config_home.mkdir(parents=True, exist_ok=True) @@ -551,23 +585,26 @@ def repair_runtime_links(config: DoctorConfig) -> RepairResult: destination = config.config_home / name try: if destination.is_symlink(): - if destination.resolve(strict=False) == source.resolve(strict=True): + current_target = lexical_link_target(destination) + if current_target == source: continue - destination.unlink() - destination.symlink_to(source, target_is_directory=True) + if current_target not in authored_sources: + blocked = True + continue + atomic_symlink_replace(destination, source) elif destination.exists(): # A regular file or directory is user-owned unless proven # otherwise. Report it, but never replace it. blocked = True else: - destination.symlink_to(source, target_is_directory=True) + atomic_symlink_replace(destination, source) except OSError: failed = True if failed: return RepairResult("panama.runtime-links", True, 1, "One or more Panama runtime links could not be recreated.") if blocked: - return RepairResult("panama.runtime-links", True, 1, "A user-owned file or directory is blocking a Panama runtime link.") + return RepairResult("panama.runtime-links", True, 1, "A user-owned runtime path is blocking a Panama link.") return RepairResult("panama.runtime-links", True, 0, "Panama runtime links were recreated. A fresh health check will verify them.") @@ -581,6 +618,32 @@ def repair_vicinae_commands(config: DoctorConfig) -> RepairResult: return RepairResult("panama.vicinae-commands", True, exit_code, message) +def parse_caffeine_pids(output: str, uid: str) -> list[int] | None: + inhibitor_pids: list[int] = [] + for line in output.splitlines(): + parts = line.split() + if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid: + continue + if len(parts) != 8: + if "Caffeine" in parts: + return None + continue + if parts[6] != "Caffeine" or parts[7] != "block": + continue + if not parts[3].isdecimal(): + return None + inhibitor_pids.append(int(parts[3])) + return inhibitor_pids + + +def close_pidfds(pidfds: list[int]) -> None: + for pidfd in pidfds: + try: + os.close(pidfd) + except OSError: + pass + + def repair_caffeine(config: DoctorConfig) -> RepairResult: list_command = ("systemd-inhibit", "--list", "--no-pager", "--no-legend") list_exit, output = run_repair_command(list_command, config) @@ -588,26 +651,39 @@ def repair_caffeine(config: DoctorConfig) -> RepairResult: return RepairResult("panama.caffeine", True, list_exit, "Caffeine inhibitors could not be inspected.") uid = str(os.getuid()) - inhibitor_pids: list[str] = [] - for line in output.splitlines(): - parts = line.split() - if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid: - continue - if len(parts) != 8: - return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.") - if parts[6] != "Caffeine" or parts[7] != "block": - continue - if not parts[3].isdecimal(): - return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.") - inhibitor_pids.append(parts[3]) + parsed_pids = parse_caffeine_pids(output, uid) + if parsed_pids is None: + return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.") + inhibitor_pids = list(dict.fromkeys(parsed_pids)) if len(inhibitor_pids) <= 1: return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.") - for pid in inhibitor_pids[1:]: - exit_code, _ = run_repair_command(("kill", "--", pid), config) - if exit_code != 0: - return RepairResult("panama.caffeine", True, exit_code, "A duplicate Panama Caffeine inhibitor could not be released.") + duplicates = inhibitor_pids[1:] + if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"): + return RepairResult("panama.caffeine", True, 1, "Safe Caffeine inhibitor release is unavailable on this system.") + + pidfds: list[int] = [] + try: + try: + pidfds = [os.pidfd_open(pid, 0) for pid in duplicates] + except (OSError, ValueError): + return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.") + + second_exit, second_output = run_repair_command(list_command, config) + if second_exit != 0: + return RepairResult("panama.caffeine", True, second_exit, "Caffeine inhibitors could not be revalidated; nothing was released.") + second_parsed = parse_caffeine_pids(second_output, uid) + if second_parsed is None or any(pid not in set(second_parsed) for pid in duplicates): + return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata changed; nothing was released.") + + try: + for pidfd in pidfds: + signal.pidfd_send_signal(pidfd, signal.SIGTERM, None, 0) + except (OSError, ValueError): + return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.") + finally: + close_pidfds(pidfds) return RepairResult("panama.caffeine", True, 0, "Duplicate Panama Caffeine inhibitors were released. A fresh health check will verify recovery.") diff --git a/config/dot/quickshell/services/Health.qml b/config/dot/quickshell/services/Health.qml index 9608dba..d79b450 100644 --- a/config/dot/quickshell/services/Health.qml +++ b/config/dot/quickshell/services/Health.qml @@ -117,6 +117,8 @@ Singleton { } function refresh(): bool { + if (root.postRepairScanPending) + return false; if (scanProcess.running || repairProcess.running) { root.queuedRefresh = true; return false; @@ -239,6 +241,8 @@ Singleton { const check = root.checks.find(candidate => candidate.id === id); if (!check || !check.action || check.action.kind !== "repair") return false; + if (external && check.action.confirm) + return false; root.repairingId = id; root.lastError = ""; diff --git a/tests/quickshell/health-service-contract.sh b/tests/quickshell/health-service-contract.sh index 61f81d2..f19ff73 100755 --- a/tests/quickshell/health-service-contract.sh +++ b/tests/quickshell/health-service-contract.sh @@ -7,10 +7,22 @@ set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -harness="$repo_dir/config/dot/quickshell/health-harness.qml" +source_harness="$repo_dir/config/dot/quickshell/health-harness.qml" service="$repo_dir/config/dot/quickshell/services/Health.qml" shell="$repo_dir/config/dot/quickshell/shell.qml" warning_snapshot='{"schemaVersion":1,"generatedAt":"2026-08-18T00:00:00Z","summary":{"status":"warning","healthy":0,"warnings":2,"errors":0,"unconfigured":0},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"warning","detail":"Duplicate inhibitors are active.","action":{"kind":"repair","label":"Release duplicate inhibitors","confirm":false}}]}' +confirm_snapshot="$(jq -c ' + .summary.status = "error" + | .summary.errors = 1 + | .checks += [{ + id: "desktop.quickshell", + group: "desktop-foundation", + title: "Quickshell", + status: "error", + detail: "Panama shell needs to restart.", + action: {kind: "repair", label: "Restart Panama", confirm: true} + }] +' <<<"$warning_snapshot")" projection_snapshot="$(jq -c ' .fixtureSecret = "fixture-secret" | .summary.fixtureSecret = "fixture-secret" @@ -33,7 +45,7 @@ fail() { } [[ -f "$service" ]] || fail 'Health.qml is missing' -[[ -f "$harness" ]] || fail 'health harness is missing' +[[ -f "$source_harness" ]] || fail 'health harness is missing' [[ -f "$shell" ]] || fail 'shell.qml is missing' # shell.qml is not started here: it is the active desktop shell. Keep this @@ -72,6 +84,33 @@ if keys != ["summary", "busy", "generation", "acceptedGeneration", "checks"]: PY fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)" +config_path="$fixture_dir/quickshell" +cp -a "$repo_dir/config/dot/quickshell" "$config_path" +harness="$config_path/health-harness.qml" +python3 - "$harness" <<'PY' +import sys + +path = sys.argv[1] +source = open(path, encoding="utf-8").read() +needle = ' function repair(id: string): bool { return Health.repair(id, false); }\n' +replacement = needle + ''' function externalRepair(id: string): bool { return Health.repair(id, true); } + function pendingRefreshRace(): string { + const before = Health.generation; + Health.finishRepair(0, "panama.caffeine", false, JSON.stringify({ + schemaVersion: 1, + checkId: "panama.caffeine", + accepted: true, + exitCode: 0, + message: "Fixture repair completed." + })); + const accepted = Health.refresh(); + return JSON.stringify({ accepted: accepted, before: before }); + } +''' +if needle not in source: + raise SystemExit("health harness repair seam is missing") +open(path, "w", encoding="utf-8").write(source.replace(needle, replacement)) +PY helper="$fixture_dir/panama-doctor" copy_bin="$fixture_dir/bin" copy_file="$fixture_dir/copied-report.json" @@ -96,6 +135,11 @@ printf '%s\n' \ ' *) printf "not-json\\n"; exit 0 ;;' \ ' esac' \ 'fi' \ + 'if [[ "$1" == "--repair" && "$2" == "desktop.quickshell" && "$3" == "--json" ]]; then' \ + ' sleep 0.25' \ + ' printf "{\"schemaVersion\":1,\"checkId\":\"desktop.quickshell\",\"accepted\":true,\"exitCode\":0,\"message\":\"Panama shell restart was requested.\"}\\n"' \ + ' exit 0' \ + 'fi' \ 'exit 2' >"$helper" chmod +x "$helper" mkdir -p "$copy_bin" @@ -243,12 +287,52 @@ jq -e '.lastRepair.checkId == "panama.caffeine" and .lastRepair.accepted == fals --argjson before "$mismatch_generation" >/dev/null <<<"$state" \ || fail "mismatched repair JSON escaped containment: $state" +# A refresh arriving after repair settlement but before the deferred mandatory +# scan is coalesced into that scan instead of starting an extra generation. +pending_race="$(run ipc call health-test pendingRefreshRace)" +jq -e '.accepted == false' >/dev/null <<<"$pending_race" \ + || fail "refresh escaped the post-repair pending window: $pending_race" +pending_generation="$(jq -r .before <<<"$pending_race")" +for _ in $(seq 1 120); do + state="$(run ipc call health-test status)" + jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' \ + --argjson before "$pending_generation" >/dev/null <<<"$state" && break + sleep 0.1 +done +jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' \ + --argjson before "$pending_generation" >/dev/null <<<"$state" \ + || fail "pending-window refresh created duplicate scans: $state" + +# External IPC cannot bypass an authored confirmation. The same current row is +# still repairable through Settings' external=false path after UI confirmation. +confirm_generation="$(jq -r .generation <<<"$state")" +[[ "$(run ipc call health-test accept "$confirm_snapshot" "$confirm_generation")" == "true" ]] \ + || fail 'confirmation fixture was rejected' +before_repair_lines="$(wc -l <"$repair_log")" +[[ "$(run ipc call health-test externalRepair desktop.quickshell)" == "false" ]] \ + || fail 'external repair bypassed confirmation' +[[ "$(wc -l <"$repair_log")" == "$before_repair_lines" ]] \ + || fail 'external confirmation rejection started a process' +[[ "$(run ipc call health-test repair desktop.quickshell)" == "true" ]] \ + || fail 'confirmed Settings repair was refused' +for _ in $(seq 1 120); do + state="$(run ipc call health-test status)" + jq -e '.busy == false and .generation == ($before + 1)' \ + --argjson before "$confirm_generation" >/dev/null <<<"$state" && break + sleep 0.1 +done +jq -e '.lastRepair == {schemaVersion:1, checkId:"desktop.quickshell", accepted:true, exitCode:0, message:"Panama shell restart was requested."} + and .generation == ($before + 1)' --argjson before "$confirm_generation" \ + >/dev/null <<<"$state" || fail "confirmed Settings repair did not complete safely: $state" +[[ "$(grep -Fc -- '--repair desktop.quickshell --json' "$repair_log")" == 1 ]] \ + || fail 'confirmed Settings repair did not start exactly one repair process' + [[ "$(run ipc call health-test repair unknown.check)" == "false" ]] \ || fail 'unknown check started a repair' [[ "$(run ipc call health-test repair integration.calendar)" == "false" ]] \ || fail 'non-repairable check started a repair' state="$(run ipc call health-test status)" -jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$mismatch_generation" \ +jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$confirm_generation" \ >/dev/null <<<"$state" || fail "rejected repair altered process state: $state" python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded' diff --git a/tests/quickshell/panama-doctor-contract.sh b/tests/quickshell/panama-doctor-contract.sh index 35fedfd..d01b0ea 100755 --- a/tests/quickshell/panama-doctor-contract.sh +++ b/tests/quickshell/panama-doctor-contract.sh @@ -16,7 +16,15 @@ fail() { } fixture="$(mktemp -d /tmp/panama-doctor.XXXXXX)" -trap 'rm -rf "$fixture"' EXIT +child_pids=() +cleanup() { + for pid in "${child_pids[@]}"; do + kill "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true + done + rm -rf "$fixture" +} +trap cleanup EXIT home="$fixture/home" config_home="$home/.config" @@ -25,7 +33,7 @@ runtime_dir="$fixture/runtime" bin_dir="$fixture/bin" data_home="$home/.local/share" -mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae" +mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts" cp "$fixture_root/bin/"* "$bin_dir/" chmod +x "$bin_dir"/* @@ -81,7 +89,7 @@ touch "$config_home/autostart/nextcloud.desktop" for name in hypr quickshell uwsm vicinae; do ln -s "$repo_dir/config/dot/$name" "$config_home/$name" done -ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts" +ln -s "$repo_dir/config/local/share/vicinae/scripts" "$data_home/vicinae/scripts/panama" run_doctor() { HOME="$home" \ @@ -130,6 +138,20 @@ check_status() { snapshot="$(run_doctor --json)" assert_schema_and_redaction "$snapshot" +check_status "$snapshot" panama.vicinae-commands ok + +# The diagnostic follows the actual installer contract: the scripts parent is +# a directory and only its Panama child is an authored link. +rm "$data_home/vicinae/scripts/panama" +unlinked_vicinae="$(run_doctor --json)" +check_status "$unlinked_vicinae" panama.vicinae-commands warning +PANAMA_PATH="$repo_dir" VICINAE_DATA_DIR="$data_home/vicinae" HOME="$home" \ + PATH="$bin_dir:/usr/bin" "$repo_dir/setup/scripts/link-vicinae-scripts" +relinked_vicinae="$(run_doctor --json)" +check_status "$relinked_vicinae" panama.vicinae-commands ok +[[ -L "$data_home/vicinae/scripts/panama" \ + && "$(readlink "$data_home/vicinae/scripts/panama")" == "$repo_dir/config/local/share/vicinae/scripts" ]] \ + || fail 'authored Vicinae helper did not create the diagnosed child link' # A healthy systemd-backed service stays healthy. check_status "$snapshot" desktop.hyprpaper ok @@ -266,23 +288,34 @@ printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" EOF -cat >"$bin_dir/kill" <<'EOF' -#!/usr/bin/bash -set -euo pipefail -printf 'kill' >>"$XDG_RUNTIME_DIR/repair.log" -printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" -printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" -EOF - cat >"$bin_dir/systemd-inhibit" <<'EOF' #!/usr/bin/bash set -euo pipefail printf 'systemd-inhibit' >>"$XDG_RUNTIME_DIR/repair.log" printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" +count_file="$XDG_RUNTIME_DIR/caffeine-list-count" +count=0 +[[ ! -f "$count_file" ]] || read -r count <"$count_file" +count=$((count + 1)) +printf '%s\n' "$count" >"$count_file" +read -r preserved duplicate <"$XDG_RUNTIME_DIR/caffeine-pids" uid="$(/usr/bin/id -u)" -printf 'Panama %s fixture-user 4101 systemd-inhibit sleep:idle Caffeine block\n' "$uid" -printf 'Panama %s fixture-user 4102 systemd-inhibit sleep:idle Caffeine block\n' "$uid" +mode="$(<"$XDG_RUNTIME_DIR/caffeine-mode")" +if [[ "$mode" == disappear && "$count" -ge 2 ]]; then + /usr/bin/touch "$XDG_RUNTIME_DIR/release-disappearing-pid" + for _ in $(/usr/bin/seq 1 100); do + [[ ! -e "/proc/$duplicate" ]] && break + /usr/bin/sleep 0.01 + done +fi +printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$preserved" +printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$preserved" +if [[ "$mode" == altered && "$count" -ge 2 ]]; then + printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Other block\n' "$uid" "$duplicate" +else + printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$duplicate" +fi printf 'Other %s fixture-user 4999 systemd-inhibit sleep:idle Caffeine block\n' "$uid" printf 'Panama 99999 fixture-user 4998 systemd-inhibit sleep:idle Caffeine block\n' printf 'Panama %s fixture-user 4997 systemd-inhibit sleep:idle Other block\n' "$uid" @@ -298,7 +331,7 @@ if (( $# > 0 )); then fi printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" EOF -chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" "$bin_dir/kill" \ +chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" \ "$bin_dir/systemd-inhibit" "$repair_root/setup/scripts/link-vicinae-scripts" run_repair() { @@ -372,50 +405,185 @@ assert_repair_result panama.vicinae-commands true 0 [[ "$(<"$repair_log")" == "link-vicinae-scripts|$repair_root/setup/scripts/link-vicinae-scripts" ]] \ || fail "Vicinae command repair argv was not exact: $(<"$repair_log")" -# Runtime-link repair may replace only the four authored symlink names. Broken -# or absent links are recreated toward authored tracked destinations; regular -# files and directories remain untouched and make the result incomplete. +# Runtime-link repair may replace only absent links or symlinks whose lexical +# target proves Panama ownership. Every other object remains untouched. for name in hypr quickshell uwsm vicinae; do path="$config_home/$name" if [[ -e "$path" || -L "$path" ]]; then mv "$path" "$fixture/pre-repair-$name" fi done -ln -s "$fixture/missing-hypr" "$config_home/hypr" -ln -s "$fixture/missing-quickshell" "$config_home/quickshell" -printf 'user-owned file\n' >"$config_home/uwsm" -mkdir "$config_home/vicinae" +ln -s "$repair_root/config/dot/hypr" "$config_home/hypr" +correct_inode="$(stat -c %i "$config_home/hypr")" +ln -s "$repair_root/config/dot/quickshell" "$config_home/uwsm" +ln -s "$fixture/external-broken-link" "$config_home/vicinae" ln -s "$fixture/untouched" "$config_home/not-panama" : >"$repair_log" invoke_repair panama.runtime-links [[ "$repair_status" == 1 ]] || fail "blocked runtime-link repair returned $repair_status" assert_repair_result panama.runtime-links true 1 [[ -L "$config_home/hypr" && "$(readlink "$config_home/hypr")" == "$repair_root/config/dot/hypr" ]] \ - || fail 'hypr link was not recreated toward its authored destination' + || fail 'correct runtime link changed' +[[ "$(stat -c %i "$config_home/hypr")" == "$correct_inode" ]] \ + || fail 'correct runtime link was replaced instead of left untouched' [[ -L "$config_home/quickshell" && "$(readlink "$config_home/quickshell")" == "$repair_root/config/dot/quickshell" ]] \ - || fail 'quickshell link was not recreated toward its authored destination' -[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \ - || fail 'runtime-link repair replaced a regular file' -[[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \ - || fail 'runtime-link repair replaced a user-owned directory' + || fail 'absent quickshell link was not created' +[[ -L "$config_home/uwsm" && "$(readlink "$config_home/uwsm")" == "$repair_root/config/dot/uwsm" ]] \ + || fail 'provably Panama-owned stale link was not repaired' +[[ -L "$config_home/vicinae" && "$(readlink "$config_home/vicinae")" == "$fixture/external-broken-link" ]] \ + || fail 'external broken symlink was replaced' [[ -L "$config_home/not-panama" && "$(readlink "$config_home/not-panama")" == "$fixture/untouched" ]] \ || fail 'runtime-link repair touched an unauthored link name' [[ ! -s "$repair_log" ]] || fail 'runtime-link repair launched a process' -# Caffeine repair parses exact authored metadata, keeps the first valid lock, -# and releases only later exact matches. +# Regular files and directories also remain untouched. +rm "$config_home/vicinae" +rm "$config_home/uwsm" +printf 'user-owned file\n' >"$config_home/uwsm" +mkdir "$config_home/vicinae" +invoke_repair panama.runtime-links +[[ "$repair_status" == 1 ]] || fail 'file/directory blockers did not make repair incomplete' +[[ -f "$config_home/uwsm" && "$(<"$config_home/uwsm")" == 'user-owned file' ]] \ + || fail 'runtime-link repair replaced a regular file' +[[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \ + || fail 'runtime-link repair replaced a user-owned directory' + +# An injected os.replace failure occurs after the authored temporary symlink is +# made; the original link must still be intact. +/usr/bin/python3 - "$doctor" "$repair_root" "$fixture/atomic-config" <<'PY' \ + || fail 'atomic replacement failure did not preserve the original link' +import importlib.util +import importlib.machinery +import os +import sys +from pathlib import Path + +doctor_path, root_text, config_text = sys.argv[1:] +loader = importlib.machinery.SourceFileLoader("panama_doctor_contract", doctor_path) +spec = importlib.util.spec_from_loader(loader.name, loader) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +loader.exec_module(module) +root = Path(root_text) +config_home = Path(config_text) +config_home.mkdir(parents=True) +destination = config_home / "hypr" +original = root / "config/dot/quickshell" +destination.symlink_to(original, target_is_directory=True) +config = module.DoctorConfig(root, config_home.parent, config_home, config_home.parent / "state", config_home.parent / "runtime", "", 0.2) +real_replace = module.os.replace +module.os.replace = lambda source, target: (_ for _ in ()).throw(OSError("fixture replacement failure")) +try: + result = module.repair_runtime_links(config) +finally: + module.os.replace = real_replace +assert result.exit_code == 1 +assert destination.is_symlink() +assert os.readlink(destination) == str(original) +assert not list(config_home.glob(".panama-link-*")) +PY + +# Caffeine repair deduplicates rows, pins each distinct duplicate with a +# pidfd, revalidates authored metadata, and signals only the duplicate. +/usr/bin/sleep 30 & +preserved_pid=$! +child_pids+=("$preserved_pid") +/usr/bin/sleep 30 & +duplicate_pid=$! +child_pids+=("$duplicate_pid") +printf '%s %s\n' "$preserved_pid" "$duplicate_pid" >"$runtime_dir/caffeine-pids" +printf 'dedupe\n' >"$runtime_dir/caffeine-mode" +rm -f "$runtime_dir/caffeine-list-count" : >"$repair_log" invoke_repair panama.caffeine [[ "$repair_status" == 0 ]] || fail "Caffeine repair returned $repair_status" assert_repair_result panama.caffeine true 0 -expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nkill|--|4102' +expected_caffeine=$'systemd-inhibit|--list|--no-pager|--no-legend\nsystemd-inhibit|--list|--no-pager|--no-legend' [[ "$(<"$repair_log")" == "$expected_caffeine" ]] \ || fail "Caffeine repair did not preserve/filter exact inhibitors: $(<"$repair_log")" +kill -0 "$preserved_pid" >/dev/null 2>&1 || fail 'repeated inhibitor rows killed the preserved process' +for _ in $(seq 1 40); do + kill -0 "$duplicate_pid" >/dev/null 2>&1 || break + sleep 0.05 +done +! kill -0 "$duplicate_pid" >/dev/null 2>&1 || fail 'distinct duplicate inhibitor was not terminated' + +# Changed second-list metadata invalidates the candidate before any signal. +/usr/bin/sleep 30 & +altered_preserved=$! +child_pids+=("$altered_preserved") +/usr/bin/sleep 30 & +altered_duplicate=$! +child_pids+=("$altered_duplicate") +printf '%s %s\n' "$altered_preserved" "$altered_duplicate" >"$runtime_dir/caffeine-pids" +printf 'altered\n' >"$runtime_dir/caffeine-mode" +rm -f "$runtime_dir/caffeine-list-count" +invoke_repair panama.caffeine +[[ "$repair_status" == 1 ]] || fail 'altered inhibitor metadata was not safely refused' +assert_repair_result panama.caffeine true 1 +kill -0 "$altered_preserved" >/dev/null 2>&1 || fail 'metadata refusal signaled the preserved process' +kill -0 "$altered_duplicate" >/dev/null 2>&1 || fail 'metadata refusal signaled the candidate process' + +# A candidate that disappears after pidfd acquisition and second-list request +# is a safe failure; an unrelated disposable process must remain untouched. +/usr/bin/sleep 30 & +unrelated_pid=$! +child_pids+=("$unrelated_pid") +( + /usr/bin/sleep 30 & + disappearing_pid=$! + trap 'kill "$disappearing_pid" >/dev/null 2>&1 || true; wait "$disappearing_pid" >/dev/null 2>&1 || true' EXIT + printf '%s\n' "$disappearing_pid" >"$runtime_dir/disappearing-pid" + while [[ ! -e "$runtime_dir/release-disappearing-pid" ]]; do + /usr/bin/sleep 0.01 + done + kill "$disappearing_pid" + wait "$disappearing_pid" >/dev/null 2>&1 || true + trap - EXIT +) & +disappearance_controller=$! +child_pids+=("$disappearance_controller") +for _ in $(seq 1 100); do + [[ -s "$runtime_dir/disappearing-pid" ]] && break + sleep 0.01 +done +[[ -s "$runtime_dir/disappearing-pid" ]] || fail 'disappearing PID fixture did not start' +disappearing_pid="$(<"$runtime_dir/disappearing-pid")" +printf '%s %s\n' "$altered_preserved" "$disappearing_pid" >"$runtime_dir/caffeine-pids" +printf 'disappear\n' >"$runtime_dir/caffeine-mode" +rm -f "$runtime_dir/caffeine-list-count" +invoke_repair panama.caffeine +[[ "$repair_status" == 1 ]] || fail 'disappeared inhibitor PID was not safely refused' +assert_repair_result panama.caffeine true 1 +wait "$disappearance_controller" +kill -0 "$unrelated_pid" >/dev/null 2>&1 || fail 'PID disappearance signaled an unrelated process' # Rejected IDs are complete JSON, exit 2, and cause neither a process launch # nor a filesystem mutation. fixture_state() { - find "$config_home" -mindepth 1 -printf '%P|%y|%l\n' | sort | sha256sum | awk '{print $1}' + /usr/bin/python3 - "$fixture" <<'PY' +import hashlib +import os +import stat +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +digest = hashlib.sha256() +for path in sorted(root.rglob("*"), key=lambda item: os.fsencode(str(item.relative_to(root)))): + relative = os.fsencode(str(path.relative_to(root))) + metadata = path.lstat() + digest.update(relative + b"\0" + oct(stat.S_IMODE(metadata.st_mode)).encode() + b"\0") + if path.is_symlink(): + digest.update(b"link\0" + os.fsencode(os.readlink(path)) + b"\0") + elif path.is_file(): + digest.update(b"file\0" + hashlib.sha256(path.read_bytes()).digest()) + elif path.is_dir(): + digest.update(b"dir\0") + else: + digest.update(b"other\0") +print(digest.hexdigest()) +PY } for rejected_id in unknown.check integration.home-assistant input.brightness \ desktop.notifications ../../escape 'desktop.vicinae;touch injected'; do From faa9a0071699e229e147033221f5772c25bd1dd6 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 11:11:53 -0400 Subject: [PATCH 2/5] Close Panama recovery race windows --- config/dot/quickshell/scripts/panama-doctor | 191 ++++++++++++++++---- tests/quickshell/panama-doctor-contract.sh | 179 +++++++++++++++--- 2 files changed, 315 insertions(+), 55 deletions(-) diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor index 2e8fa03..9025015 100755 --- a/config/dot/quickshell/scripts/panama-doctor +++ b/config/dot/quickshell/scripts/panama-doctor @@ -5,6 +5,8 @@ from __future__ import annotations import argparse +import ctypes +import errno import json import os import re @@ -23,6 +25,11 @@ from typing import Callable, Literal Status = Literal["ok", "warning", "error", "unconfigured"] Group = Literal["desktop-foundation", "input-media", "integrations", "panama-tools"] ActionKind = Literal["repair", "open", "instructions"] +InhibitorRow = tuple[str, str, str, str, str, str, str, str] + +AT_FDCWD = -100 +RENAME_NOREPLACE = 1 +RENAME_EXCHANGE = 2 @dataclass(frozen=True) @@ -452,10 +459,10 @@ def check_caffeine(config: DoctorConfig) -> Check: result = run_command(("systemd-inhibit", "--list", "--no-pager", "--no-legend"), config) if result.state != "ok": return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe is unavailable.") - inhibitor_pids = parse_caffeine_pids(result.stdout, str(os.getuid())) - if inhibitor_pids is None: + inhibitor_rows = parse_caffeine_rows(result.stdout, str(os.getuid())) + if inhibitor_rows is None: return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Caffeine inhibitor probe returned an invalid result.") - inhibitors = len(dict.fromkeys(inhibitor_pids)) + inhibitors = len(dict.fromkeys(int(row[3]) for row in inhibitor_rows)) if inhibitors > 1: return Check("panama.caffeine", "panama-tools", "Caffeine inhibitor", "warning", "Duplicate Panama Caffeine inhibitors detected.", Action("repair", "Release duplicate inhibitors")) if inhibitors == 1: @@ -542,29 +549,120 @@ def lexical_link_target(destination: Path) -> Path: return lexical_path(target if target.is_absolute() else destination.parent / target) -def atomic_symlink_replace(destination: Path, source: Path) -> None: - """Install an authored sibling symlink without first removing destination.""" +def renameat2(source: Path, destination: Path, flags: int) -> None: + """Call Linux renameat2 with fixed flags selected by authored code.""" + libc = ctypes.CDLL(None, use_errno=True) + function = getattr(libc, "renameat2", None) + if function is None: + raise OSError(errno.ENOSYS, "renameat2 is unavailable") + function.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + function.restype = ctypes.c_int + result = function( + AT_FDCWD, + os.fsencode(source), + AT_FDCWD, + os.fsencode(destination), + flags, + ) + if result != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), destination) + + +def rename_exchange(source: Path, destination: Path) -> None: + renameat2(source, destination, RENAME_EXCHANGE) + + +def rename_noreplace(source: Path, destination: Path) -> None: + renameat2(source, destination, RENAME_NOREPLACE) + + +def create_symlink_candidate(destination: Path, source: Path) -> Path: + """Create one unpredictable authored sibling candidate symlink.""" for _ in range(32): - temporary = destination.with_name( + candidate = destination.with_name( f".panama-link-{destination.name}-{os.getpid()}-{secrets.token_hex(8)}" ) - created = False try: - os.symlink(source, temporary, target_is_directory=True) - created = True - os.replace(temporary, destination) - return + os.symlink(source, candidate, target_is_directory=True) + return candidate except FileExistsError: continue - finally: - if created: - try: - temporary.unlink() - except FileNotFoundError: - pass raise OSError("Could not allocate an authored temporary link") +def cleanup_candidate(candidate: Path) -> None: + try: + candidate.unlink() + except FileNotFoundError: + pass + + +def install_absent_symlink(destination: Path, source: Path) -> Literal["repaired", "blocked", "failed"]: + candidate = create_symlink_candidate(destination, source) + try: + try: + rename_noreplace(candidate, destination) + except FileExistsError: + return "blocked" + except OSError: + return "failed" + return "repaired" + finally: + cleanup_candidate(candidate) + + +def exchange_owned_symlink( + destination: Path, + source: Path, + authored_sources: frozenset[Path], +) -> Literal["repaired", "blocked", "failed"]: + """Exchange first, then validate the exact object removed from destination.""" + candidate = create_symlink_candidate(destination, source) + exchanged = False + rolled_back = False + try: + try: + rename_exchange(candidate, destination) + exchanged = True + except OSError: + return "failed" + + try: + old_is_authored = candidate.is_symlink() \ + and lexical_link_target(candidate) in authored_sources + except OSError: + old_is_authored = False + if old_is_authored: + cleanup_candidate(candidate) + return "repaired" + + try: + rename_exchange(candidate, destination) + rolled_back = True + except OSError: + # The displaced object remains at the unpredictable candidate path; + # never unlink it when rollback could not restore ownership. + return "failed" + + try: + restored_candidate_is_ours = candidate.is_symlink() \ + and lexical_link_target(candidate) == source + except OSError: + restored_candidate_is_ours = False + if not restored_candidate_is_ours: + return "failed" + cleanup_candidate(candidate) + return "blocked" + finally: + if not exchanged or rolled_back: + try: + if candidate.is_symlink() and lexical_link_target(candidate) == source: + cleanup_candidate(candidate) + except OSError: + pass + + def repair_runtime_links(config: DoctorConfig) -> RepairResult: root = lexical_path(config.root) sources = [(name, lexical_path(config.root / relative_source)) for name, relative_source in RUNTIME_LINK_TARGETS] @@ -591,13 +689,17 @@ def repair_runtime_links(config: DoctorConfig) -> RepairResult: if current_target not in authored_sources: blocked = True continue - atomic_symlink_replace(destination, source) + outcome = exchange_owned_symlink(destination, source, authored_sources) + blocked = blocked or outcome == "blocked" + failed = failed or outcome == "failed" elif destination.exists(): # A regular file or directory is user-owned unless proven # otherwise. Report it, but never replace it. blocked = True else: - atomic_symlink_replace(destination, source) + outcome = install_absent_symlink(destination, source) + blocked = blocked or outcome == "blocked" + failed = failed or outcome == "failed" except OSError: failed = True @@ -618,8 +720,8 @@ def repair_vicinae_commands(config: DoctorConfig) -> RepairResult: return RepairResult("panama.vicinae-commands", True, exit_code, message) -def parse_caffeine_pids(output: str, uid: str) -> list[int] | None: - inhibitor_pids: list[int] = [] +def parse_caffeine_rows(output: str, uid: str) -> list[InhibitorRow] | None: + inhibitor_rows: list[InhibitorRow] = [] for line in output.splitlines(): parts = line.split() if len(parts) < 2 or parts[0] != "Panama" or parts[1] != uid: @@ -632,8 +734,8 @@ def parse_caffeine_pids(output: str, uid: str) -> list[int] | None: continue if not parts[3].isdecimal(): return None - inhibitor_pids.append(int(parts[3])) - return inhibitor_pids + inhibitor_rows.append(tuple(parts)) + return inhibitor_rows def close_pidfds(pidfds: list[int]) -> None: @@ -644,6 +746,31 @@ def close_pidfds(pidfds: list[int]) -> None: pass +def signal_caffeine_pidfds( + pidfds: list[int], + sender: Callable[..., None] | None = None, +) -> Literal["released", "preflight-failed", "incomplete"]: + send = sender or signal.pidfd_send_signal + for pidfd in pidfds: + try: + send(pidfd, 0, None, 0) + except (OSError, ValueError): + return "preflight-failed" + + incomplete = False + for pidfd in pidfds: + try: + send(pidfd, signal.SIGTERM, None, 0) + except ProcessLookupError: + continue + except OSError as error: + if error.errno != errno.ESRCH: + incomplete = True + except ValueError: + incomplete = True + return "incomplete" if incomplete else "released" + + def repair_caffeine(config: DoctorConfig) -> RepairResult: list_command = ("systemd-inhibit", "--list", "--no-pager", "--no-legend") list_exit, output = run_repair_command(list_command, config) @@ -651,10 +778,10 @@ def repair_caffeine(config: DoctorConfig) -> RepairResult: return RepairResult("panama.caffeine", True, list_exit, "Caffeine inhibitors could not be inspected.") uid = str(os.getuid()) - parsed_pids = parse_caffeine_pids(output, uid) - if parsed_pids is None: + inhibitor_rows = parse_caffeine_rows(output, uid) + if inhibitor_rows is None: return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata was invalid; nothing was released.") - inhibitor_pids = list(dict.fromkeys(parsed_pids)) + inhibitor_pids = list(dict.fromkeys(int(row[3]) for row in inhibitor_rows)) if len(inhibitor_pids) <= 1: return RepairResult("panama.caffeine", True, 0, "No duplicate Panama Caffeine inhibitors needed release.") @@ -673,15 +800,15 @@ def repair_caffeine(config: DoctorConfig) -> RepairResult: second_exit, second_output = run_repair_command(list_command, config) if second_exit != 0: return RepairResult("panama.caffeine", True, second_exit, "Caffeine inhibitors could not be revalidated; nothing was released.") - second_parsed = parse_caffeine_pids(second_output, uid) - if second_parsed is None or any(pid not in set(second_parsed) for pid in duplicates): + second_rows = parse_caffeine_rows(second_output, uid) + if second_rows is None or second_rows != inhibitor_rows: return RepairResult("panama.caffeine", True, 1, "Caffeine inhibitor metadata changed; nothing was released.") - try: - for pidfd in pidfds: - signal.pidfd_send_signal(pidfd, signal.SIGTERM, None, 0) - except (OSError, ValueError): + signal_outcome = signal_caffeine_pidfds(pidfds) + if signal_outcome == "preflight-failed": return RepairResult("panama.caffeine", True, 1, "A duplicate inhibitor changed before it could be safely released.") + if signal_outcome == "incomplete": + return RepairResult("panama.caffeine", True, 1, "One or more duplicate inhibitors could not be released.") finally: close_pidfds(pidfds) return RepairResult("panama.caffeine", True, 0, "Duplicate Panama Caffeine inhibitors were released. A fresh health check will verify recovery.") diff --git a/tests/quickshell/panama-doctor-contract.sh b/tests/quickshell/panama-doctor-contract.sh index d01b0ea..33c8f0a 100755 --- a/tests/quickshell/panama-doctor-contract.sh +++ b/tests/quickshell/panama-doctor-contract.sh @@ -259,12 +259,14 @@ summary="$(run_doctor --summary)" # Repairs run against a second, disposable Panama root. Every process boundary # records its argv, and every filesystem assertion is confined to this fixture. -repair_root="$fixture/repair-root" +repair_root="$home/.local/share/Panama" repair_log="$runtime_dir/repair.log" -mkdir -p "$repair_root/config/dot" "$repair_root/setup/scripts" +mkdir -p "$repair_root/config/dot" "$repair_root/config/local/share/vicinae/scripts" \ + "$repair_root/setup/scripts" for name in hypr quickshell uwsm vicinae; do mkdir -p "$repair_root/config/dot/$name" done +cp "$repo_dir/setup/scripts/link-vicinae-scripts" "$repair_root/setup/scripts/link-vicinae-scripts" mv "$bin_dir/systemctl" "$bin_dir/systemctl-probe" cat >"$bin_dir/systemctl" <<'EOF' @@ -309,8 +311,14 @@ if [[ "$mode" == disappear && "$count" -ge 2 ]]; then /usr/bin/sleep 0.01 done fi -printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$preserved" -printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$preserved" +preserved_comm=systemd-inhibit +if [[ "$mode" == preserve-altered && "$count" -ge 2 ]]; then + preserved_comm=changed-command +fi +printf 'Panama %s fixture-user %s %s sleep:idle Caffeine block\n' "$uid" "$preserved" "$preserved_comm" +if [[ "$mode" != multiplicity || "$count" -lt 2 ]]; then + printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Caffeine block\n' "$uid" "$preserved" +fi if [[ "$mode" == altered && "$count" -ge 2 ]]; then printf 'Panama %s fixture-user %s systemd-inhibit sleep:idle Other block\n' "$uid" "$duplicate" else @@ -322,28 +330,19 @@ printf 'Panama %s fixture-user 4997 systemd-inhibit sleep:idle Other block\n' "$ printf 'Panama %s fixture-user 4996 systemd-inhibit sleep:idle Caffeine delay\n' "$uid" EOF -cat >"$repair_root/setup/scripts/link-vicinae-scripts" <<'EOF' -#!/usr/bin/bash -set -euo pipefail -printf 'link-vicinae-scripts|%s' "$0" >>"$XDG_RUNTIME_DIR/repair.log" -if (( $# > 0 )); then - printf '|%s' "$@" >>"$XDG_RUNTIME_DIR/repair.log" -fi -printf '\n' >>"$XDG_RUNTIME_DIR/repair.log" -EOF chmod +x "$bin_dir/systemctl" "$bin_dir/panama-action" \ "$bin_dir/systemd-inhibit" "$repair_root/setup/scripts/link-vicinae-scripts" run_repair() { HOME="$home" \ - PATH="$bin_dir" \ + PATH="$bin_dir:/usr/bin" \ XDG_CURRENT_DESKTOP=Hyprland \ PANAMA_DOCTOR_ROOT="$repair_root" \ PANAMA_DOCTOR_HOME="$home" \ PANAMA_DOCTOR_CONFIG_HOME="$config_home" \ PANAMA_DOCTOR_STATE_HOME="$state_home" \ PANAMA_DOCTOR_RUNTIME_DIR="$runtime_dir" \ - PANAMA_DOCTOR_PATH="$bin_dir" \ + PANAMA_DOCTOR_PATH="$bin_dir:/usr/bin" \ PANAMA_DOCTOR_TIMEOUT=0.2 \ /usr/bin/python3 "$doctor" "$@" } @@ -397,13 +396,19 @@ assert_repair_result desktop.vicinae true 5 [[ "$(<"$repair_log")" == 'systemctl|--user|restart|vicinae.service' ]] \ || fail 'failed repair changed the authored argv' -# The Vicinae repair executes only the authored setup helper with no arguments. -: >"$repair_log" +# The real authored Vicinae helper converges the exact child link diagnosed by +# panama-doctor under the isolated HOME. +rm -f "$data_home/vicinae/scripts/panama" +before_vicinae_repair="$(run_repair --json)" +check_status "$before_vicinae_repair" panama.vicinae-commands warning invoke_repair panama.vicinae-commands [[ "$repair_status" == 0 ]] || fail "Vicinae command repair returned $repair_status" assert_repair_result panama.vicinae-commands true 0 -[[ "$(<"$repair_log")" == "link-vicinae-scripts|$repair_root/setup/scripts/link-vicinae-scripts" ]] \ - || fail "Vicinae command repair argv was not exact: $(<"$repair_log")" +after_vicinae_repair="$(run_repair --json)" +check_status "$after_vicinae_repair" panama.vicinae-commands ok +[[ -L "$data_home/vicinae/scripts/panama" \ + && "$(readlink "$data_home/vicinae/scripts/panama")" == "$repair_root/config/local/share/vicinae/scripts" ]] \ + || fail 'Vicinae repair did not install the diagnosed child link' # Runtime-link repair may replace only absent links or symlinks whose lexical # target proves Panama ownership. Every other object remains untouched. @@ -448,7 +453,7 @@ invoke_repair panama.runtime-links [[ -d "$config_home/vicinae" && ! -L "$config_home/vicinae" ]] \ || fail 'runtime-link repair replaced a user-owned directory' -# An injected os.replace failure occurs after the authored temporary symlink is +# An injected exchange failure occurs after the authored candidate symlink is # made; the original link must still be intact. /usr/bin/python3 - "$doctor" "$repair_root" "$fixture/atomic-config" <<'PY' \ || fail 'atomic replacement failure did not preserve the original link' @@ -471,18 +476,66 @@ destination = config_home / "hypr" original = root / "config/dot/quickshell" destination.symlink_to(original, target_is_directory=True) config = module.DoctorConfig(root, config_home.parent, config_home, config_home.parent / "state", config_home.parent / "runtime", "", 0.2) -real_replace = module.os.replace -module.os.replace = lambda source, target: (_ for _ in ()).throw(OSError("fixture replacement failure")) +real_exchange = module.rename_exchange +module.rename_exchange = lambda source, target: (_ for _ in ()).throw(OSError("fixture exchange failure")) try: result = module.repair_runtime_links(config) finally: - module.os.replace = real_replace + module.rename_exchange = real_exchange assert result.exit_code == 1 assert destination.is_symlink() assert os.readlink(destination) == str(original) assert not list(config_home.glob(".panama-link-*")) PY +# A deterministic swap at the ownership/replacement boundary must be detected +# from the exchanged-out object and rolled back, preserving the external link. +/usr/bin/python3 - "$doctor" "$repair_root" "$fixture/toctou-config" "$fixture/external-race-target" <<'PY' \ + || fail 'runtime-link exchange did not restore a boundary-swapped external link' +import importlib.machinery +import importlib.util +import os +import sys +from pathlib import Path + +doctor_path, root_text, config_text, external_text = sys.argv[1:] +loader = importlib.machinery.SourceFileLoader("panama_doctor_toctou", doctor_path) +spec = importlib.util.spec_from_loader(loader.name, loader) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +loader.exec_module(module) +root = Path(root_text) +config_home = Path(config_text) +config_home.mkdir(parents=True) +for name, relative in module.RUNTIME_LINK_TARGETS: + (config_home / name).symlink_to(root / relative, target_is_directory=True) +destination = config_home / "uwsm" +destination.unlink() +destination.symlink_to(root / "config/dot/quickshell", target_is_directory=True) +external = Path(external_text) +real_exchange = module.rename_exchange +first = True + +def race_exchange(candidate, target): + global first + if first: + first = False + target.unlink() + target.symlink_to(external, target_is_directory=True) + real_exchange(candidate, target) + +module.rename_exchange = race_exchange +config = module.DoctorConfig(root, config_home.parent, config_home, config_home.parent / "state", config_home.parent / "runtime", "", 0.2) +try: + result = module.repair_runtime_links(config) +finally: + module.rename_exchange = real_exchange +assert result.exit_code == 1 +assert destination.is_symlink() +assert os.readlink(destination) == str(external) +assert not list(config_home.glob(".panama-link-*")) +PY + # Caffeine repair deduplicates rows, pins each distinct duplicate with a # pidfd, revalidates authored metadata, and signals only the duplicate. /usr/bin/sleep 30 & @@ -524,6 +577,86 @@ assert_repair_result panama.caffeine true 1 kill -0 "$altered_preserved" >/dev/null 2>&1 || fail 'metadata refusal signaled the preserved process' kill -0 "$altered_duplicate" >/dev/null 2>&1 || fail 'metadata refusal signaled the candidate process' +# Changing metadata on the preserved row is also a full-identity mismatch, +# even though every duplicate PID remains present. +/usr/bin/sleep 30 & +preserve_changed_keep=$! +child_pids+=("$preserve_changed_keep") +/usr/bin/sleep 30 & +preserve_changed_duplicate=$! +child_pids+=("$preserve_changed_duplicate") +printf '%s %s\n' "$preserve_changed_keep" "$preserve_changed_duplicate" >"$runtime_dir/caffeine-pids" +printf 'preserve-altered\n' >"$runtime_dir/caffeine-mode" +rm -f "$runtime_dir/caffeine-list-count" +invoke_repair panama.caffeine +[[ "$repair_status" == 1 ]] || fail 'preserved-row metadata change was not safely refused' +assert_repair_result panama.caffeine true 1 +kill -0 "$preserve_changed_keep" >/dev/null 2>&1 || fail 'preserved-row mismatch signaled the preserved process' +kill -0 "$preserve_changed_duplicate" >/dev/null 2>&1 || fail 'preserved-row mismatch signaled the duplicate process' + +# A repeated exact row disappearing between lists changes multiplicity and is +# refused before signaling any pinned duplicate. +/usr/bin/sleep 30 & +multiplicity_keep=$! +child_pids+=("$multiplicity_keep") +/usr/bin/sleep 30 & +multiplicity_duplicate=$! +child_pids+=("$multiplicity_duplicate") +printf '%s %s\n' "$multiplicity_keep" "$multiplicity_duplicate" >"$runtime_dir/caffeine-pids" +printf 'multiplicity\n' >"$runtime_dir/caffeine-mode" +rm -f "$runtime_dir/caffeine-list-count" +invoke_repair panama.caffeine +[[ "$repair_status" == 1 ]] || fail 'inhibitor row multiplicity change was not safely refused' +assert_repair_result panama.caffeine true 1 +kill -0 "$multiplicity_keep" >/dev/null 2>&1 || fail 'multiplicity mismatch signaled the preserved process' +kill -0 "$multiplicity_duplicate" >/dev/null 2>&1 || fail 'multiplicity mismatch signaled the duplicate process' + +# The production pidfd release function preflights every candidate before any +# SIGTERM. A refused second preflight leaves both disposable children alive. +/usr/bin/sleep 30 & +preflight_first=$! +child_pids+=("$preflight_first") +/usr/bin/sleep 30 & +preflight_second=$! +child_pids+=("$preflight_second") +/usr/bin/python3 - "$doctor" "$preflight_first" "$preflight_second" <<'PY' \ + || fail 'pidfd preflight failure signaled a disposable duplicate' +import errno +import importlib.machinery +import importlib.util +import os +import signal +import sys + +doctor_path = sys.argv[1] +pids = [int(value) for value in sys.argv[2:]] +loader = importlib.machinery.SourceFileLoader("panama_doctor_preflight", doctor_path) +spec = importlib.util.spec_from_loader(loader.name, loader) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +loader.exec_module(module) +pidfds = [os.pidfd_open(pid, 0) for pid in pids] +calls = [] + +def sender(pidfd, sig, siginfo, flags): + calls.append(sig) + if sig == 0 and pidfd == pidfds[1]: + raise PermissionError(errno.EPERM, "fixture preflight refusal") + signal.pidfd_send_signal(pidfd, sig, siginfo, flags) + +try: + outcome = module.signal_caffeine_pidfds(pidfds, sender) +finally: + for pidfd in pidfds: + os.close(pidfd) +assert outcome == "preflight-failed" +assert calls == [0, 0] +for pid in pids: + os.kill(pid, 0) +PY +kill -0 "$preflight_first" >/dev/null 2>&1 || fail 'preflight refusal killed the first duplicate' +kill -0 "$preflight_second" >/dev/null 2>&1 || fail 'preflight refusal killed the second duplicate' + # A candidate that disappears after pidfd acquisition and second-list request # is a safe failure; an unrelated disposable process must remain untouched. /usr/bin/sleep 30 & From f8e7512e01291b812fe4bab505848a22350ae3ef Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 11:36:06 -0400 Subject: [PATCH 3/5] Document Panama health and recovery --- config/dot/hypr/DESKTOP-PARITY.md | 25 ++++++++++++++++++ .../dot/quickshell/modules/settings/README.md | 25 ++++++++++++++++++ .../2026-08-18-panama-health-recovery.md | 26 +++++++++++++++++-- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/config/dot/hypr/DESKTOP-PARITY.md b/config/dot/hypr/DESKTOP-PARITY.md index 6d22840..f80250d 100644 --- a/config/dot/hypr/DESKTOP-PARITY.md +++ b/config/dot/hypr/DESKTOP-PARITY.md @@ -37,6 +37,31 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0. | Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live | | Printer administration | CUPS with the `system-config-printer` graphical interface | Live | | System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live | +| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live | + +## System health and recovery + +Panama stays silent while the desktop is healthy. A compact bar indicator +appears only for actionable warnings or errors and opens the same **System +Health** page available from Settings and the Vicinae command **Panama: Check +System Health**. The terminal summary is available with: + +```bash +~/.config/quickshell/scripts/panama-doctor --summary +``` + +The doctor reports authored, redacted observations about Panama-owned services, +tools, links, and configured integrations. It does not read secrets, clipboard +or notification contents, calendar events, SSIDs, or device addresses. Repairs +are a small allow-list: Panama user services, Panama-owned links and launcher +commands, duplicate Panama Caffeine inhibitors, and a confirmed shell restart. +They never install packages, invoke `sudo`, delete user data, or rewrite +arbitrary configuration. + +Generic Fedora configuration remains with the system tools that own it. The +final System Health card hands network settings, users, sharing, colour +profiles, and digital wellbeing to their exact GNOME Settings panels rather +than presenting inert Hyprland controls. ## GNOME extension migration diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index 31dc5ea..a2db36c 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -4,6 +4,31 @@ The control centre for everything Panama owns. Anything the system owns — hardware, accounts, printers — is delegated to GNOME Settings and labelled as such rather than half-reimplemented. +## System Health + +The stable internal `services` route renders **System Health**. It is reachable +from the Settings sidebar and its live 54px footer, the degraded-only bar +indicator, and Vicinae's **Panama: Check System Health** command. Healthy scans +reserve no bar space and produce no notification. + +`services/Health.qml` owns the last accepted redacted snapshot and invokes only +`scripts/panama-doctor`. For a concise terminal view, run: + +```bash +~/.config/quickshell/scripts/panama-doctor --summary +``` + +The helper diagnoses Panama-owned desktop services, dependencies, links, and +configured integrations. It does not read secret values, clipboard or +notification contents, calendar events, SSIDs, addresses, or arbitrary command +output. Its repair interface is an authored allow-list: it never installs a +package, runs `sudo`, deletes user data, or repairs a service Panama does not +own. A repair remains degraded until a fresh scan observes recovery. + +The final card is the ownership boundary. Network configuration and the exact +Users, Sharing, Colour profiles, and Digital wellbeing handoffs open GNOME +Settings because Fedora's system services own those areas. + ## Adding a setting One schema entry. That is the whole job. diff --git a/docs/superpowers/plans/2026-08-18-panama-health-recovery.md b/docs/superpowers/plans/2026-08-18-panama-health-recovery.md index a0ff53b..82dbe9b 100644 --- a/docs/superpowers/plans/2026-08-18-panama-health-recovery.md +++ b/docs/superpowers/plans/2026-08-18-panama-health-recovery.md @@ -498,6 +498,19 @@ git commit -m "Add bounded Panama recovery actions" ### Task 7: Full verification, live read-only audit, and documentation +**Integration note:** `origin/main` added Mouse, Privacy, Region, and Online +Accounts destinations while this feature was in review. Merge commit `4ef2f01` +preserves those routes and the newer Settings navigation architecture, keeps +the stable `services` route rendered by `HealthPage`, and leaves +`ServicesPage.qml` retired. Its useful Fedora handoffs for Users, Sharing, +Colour profiles, and Digital wellbeing now live in the boundary-last System +Health card alongside the existing network handoff, with focused static and +isolated runtime coverage. + +**Live-audit handoff:** Per the integration brief, this task does not reload the +daily-driver Quickshell, invoke a live repair, or run the read-only live +doctor/IPC comparison. Those checks remain for the controller after code review. + **Files:** - Modify: `config/dot/hypr/DESKTOP-PARITY.md` - Modify: `config/dot/quickshell/modules/settings/README.md` @@ -507,7 +520,7 @@ git commit -m "Add bounded Panama recovery actions" - Consumes: the complete feature and existing regression suite. - Produces: current user documentation, a redacted live health snapshot, and final verification evidence. -- [ ] **Step 1: Document boundaries and entry points** +- [x] **Step 1: Document boundaries and entry points** Document `Panama: Check System Health`, Settings → System Health, the degraded-only bar indicator, `panama-doctor --summary`, the no-`sudo`/no-package-install boundary, and the fact that GNOME/Fedora tools remain responsible for generic system configuration. @@ -525,7 +538,16 @@ for test in tests/quickshell/*contract.sh; do "$test"; done for test in tests/hypr/*contract.sh; do "$test"; done ``` -Expected: every command exits 0; Quickshell tests report 58 contracts after the three new contracts land. +Expected: every command exits 0. After the Settings parity merge, the current +inventory is 65 Quickshell contracts and 2 Hyprland contracts (the original +pre-merge estimate was 58). + +Integration result: syntax and all focused Health/Settings contracts pass. Two +complete serial Quickshell runs each passed 63/65, but failed on different +order-sensitive contracts. Run one failed Displays and Settings Hyprland Write; +run two failed Focus Session and Health Service. Each failed contract passed +immediately when rerun alone. Hyprland contracts passed 2/2. No out-of-scope +test or service code was changed to hide this suite-order interference. - [ ] **Step 3: Run a redacted live read-only comparison** From 60a321e6f4989fd623aadcab546fbd1548cbc3ce Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 12:11:56 -0400 Subject: [PATCH 4/5] Harden Health verification isolation --- .../dot/quickshell/modules/settings/README.md | 17 +- .../2026-08-18-panama-health-recovery.md | 20 ++- tests/quickshell/health-service-contract.sh | 69 +++++++- tests/quickshell/settings-pages-contract.sh | 157 +++++++++++++++--- 4 files changed, 223 insertions(+), 40 deletions(-) diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index a2db36c..e037ad1 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -11,8 +11,10 @@ from the Settings sidebar and its live 54px footer, the degraded-only bar indicator, and Vicinae's **Panama: Check System Health** command. Healthy scans reserve no bar space and produce no notification. -`services/Health.qml` owns the last accepted redacted snapshot and invokes only -`scripts/panama-doctor`. For a concise terminal view, run: +`services/Health.qml` owns the last accepted redacted snapshot. It invokes +`scripts/panama-doctor` for scans and bounded repairs, `wl-copy` only for an +explicit **Copy Report**, and bounded `notify-send` only when an external repair +fails. For a concise terminal view, run: ```bash ~/.config/quickshell/scripts/panama-doctor --summary @@ -108,11 +110,12 @@ slot**, because that is the row's default property, so only the right-hand edge becomes clickable. Use `activatable: true` with `onActivated` for a whole-row target. -**A copy of the Quickshell config shares the live shell's ID.** Quickshell -derives the Shell ID from config *content*, not path, so -`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running -desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a -single distinct `.qml` file are safe; copying the whole directory is not. +**A content-identical Quickshell entry can share the live shell's ID.** +Quickshell derives the Shell ID from config *content*, not path. Runtime +harnesses therefore create a distinct semantic entry file, address that exact +file with `qs -p`, and discover its PID from the exact Config path in +`qs list --all`. They terminate only that recorded PID with `kill`; never use +`qs kill` from a copied configuration. ## Where state lives diff --git a/docs/superpowers/plans/2026-08-18-panama-health-recovery.md b/docs/superpowers/plans/2026-08-18-panama-health-recovery.md index 82dbe9b..3417bec 100644 --- a/docs/superpowers/plans/2026-08-18-panama-health-recovery.md +++ b/docs/superpowers/plans/2026-08-18-panama-health-recovery.md @@ -496,7 +496,7 @@ git add config/dot/quickshell/scripts/panama-doctor config/dot/quickshell/servic git commit -m "Add bounded Panama recovery actions" ``` -### Task 7: Full verification, live read-only audit, and documentation +### Task 7: Full verification, controller-deferred live audit, and documentation **Integration note:** `origin/main` added Mouse, Privacy, Region, and Online Accounts destinations while this feature was in review. Merge commit `4ef2f01` @@ -518,7 +518,9 @@ doctor/IPC comparison. Those checks remain for the controller after code review. **Interfaces:** - Consumes: the complete feature and existing regression suite. -- Produces: current user documentation, a redacted live health snapshot, and final verification evidence. +- Produces: current user documentation and final contract evidence. The + redacted live health snapshot and live shell audit are deferred to the + controller after code review. - [x] **Step 1: Document boundaries and entry points** @@ -538,8 +540,8 @@ for test in tests/quickshell/*contract.sh; do "$test"; done for test in tests/hypr/*contract.sh; do "$test"; done ``` -Expected: every command exits 0. After the Settings parity merge, the current -inventory is 65 Quickshell contracts and 2 Hyprland contracts (the original +Expected: every command exits 0. After the latest Settings and installer work, +the current inventory is 66 Quickshell contracts and 2 Hyprland contracts (the original pre-merge estimate was 58). Integration result: syntax and all focused Health/Settings contracts pass. Two @@ -549,7 +551,10 @@ run two failed Focus Session and Health Service. Each failed contract passed immediately when rerun alone. Hyprland contracts passed 2/2. No out-of-scope test or service code was changed to hide this suite-order interference. -- [ ] **Step 3: Run a redacted live read-only comparison** +- [ ] **Step 3: Controller runs a redacted live read-only comparison** + +Deferred to the controller after code review; Task 7 does not produce this +live output. Run: @@ -563,7 +568,10 @@ qs ipc call health status | jq '{status, busy, checks: [.checks[] | {id, status} Expected: helper and direct service states agree. Do not print details from integrations; copied and IPC reports contain only redacted authored observations. -- [ ] **Step 4: Reload and inspect the live shell** +- [ ] **Step 4: Controller reloads and inspects the live shell** + +Deferred to the controller after code review; Task 7 does not reload or inspect +the daily-driver shell. Run: diff --git a/tests/quickshell/health-service-contract.sh b/tests/quickshell/health-service-contract.sh index f19ff73..4482808 100755 --- a/tests/quickshell/health-service-contract.sh +++ b/tests/quickshell/health-service-contract.sh @@ -117,6 +117,8 @@ copy_file="$fixture_dir/copied-report.json" repair_mode_file="$fixture_dir/repair-mode" repair_log="$fixture_dir/repair.log" notification_log="$fixture_dir/notifications.log" +repair_started_file="$fixture_dir/repair-started" +repair_release_file="$fixture_dir/repair-release" printf 'success\n' >"$repair_mode_file" printf '%s\n' \ '#!/usr/bin/env bash' \ @@ -126,8 +128,12 @@ printf '%s\n' \ " printf '%s\\n' '$warning_snapshot'" \ ' exit 0' \ 'fi' \ + 'if [[ "$1" == "--repair" ]]; then' \ + ' repair_start_time="$(awk '\''{ print $22 }'\'' "/proc/$$/stat")"' \ + ' printf "%s|%s\n" "$$" "$repair_start_time" >"$PANAMA_HEALTH_REPAIR_STARTED"' \ + ' while [[ ! -e "$PANAMA_HEALTH_REPAIR_RELEASE" ]]; do sleep 0.02; done' \ + 'fi' \ 'if [[ "$1" == "--repair" && "$2" == "panama.caffeine" && "$3" == "--json" ]]; then' \ - ' sleep 0.25' \ ' case "$(cat "$PANAMA_HEALTH_REPAIR_MODE_FILE")" in' \ ' success) printf "{\"schemaVersion\":1,\"checkId\":\"panama.caffeine\",\"accepted\":true,\"exitCode\":0,\"message\":\"Duplicate inhibitors were released.\"}\\n"; exit 0 ;;' \ ' failed) printf "{\"schemaVersion\":1,\"checkId\":\"panama.caffeine\",\"accepted\":true,\"exitCode\":7,\"message\":\"Duplicate inhibitors could not be released.\"}\\n"; exit 7 ;;' \ @@ -136,7 +142,6 @@ printf '%s\n' \ ' esac' \ 'fi' \ 'if [[ "$1" == "--repair" && "$2" == "desktop.quickshell" && "$3" == "--json" ]]; then' \ - ' sleep 0.25' \ ' printf "{\"schemaVersion\":1,\"checkId\":\"desktop.quickshell\",\"accepted\":true,\"exitCode\":0,\"message\":\"Panama shell restart was requested.\"}\\n"' \ ' exit 0' \ 'fi' \ @@ -154,12 +159,57 @@ chmod +x "$copy_bin/wl-copy" "$copy_bin/notify-send" run() { PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \ PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \ - PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" qs -p "$harness" "$@" + PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \ + PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \ + qs -p "$harness" "$@" } harness_pid="" +harness_start_time="" + +process_identity_matches() { + local pid="$1" expected_start_time="$2" expected_command="${3:-}" current_start_time + + [[ "$pid" =~ ^[0-9]+$ && "$expected_start_time" =~ ^[0-9]+$ ]] || return 1 + [[ -r "/proc/$pid/stat" ]] || return 1 + current_start_time="$(awk '{ print $22 }' "/proc/$pid/stat" 2>/dev/null)" || return 1 + [[ "$current_start_time" == "$expected_start_time" ]] || return 1 + if [[ -n "$expected_command" ]]; then + [[ -r "/proc/$pid/cmdline" ]] || return 1 + tr '\0' '\n' <"/proc/$pid/cmdline" | grep -Fxq "$expected_command" + fi +} cleanup() { - [[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true + : >"$repair_release_file" + if [[ -f "$repair_started_file" ]]; then + IFS='|' read -r repair_pid repair_start_time <"$repair_started_file" || true + if process_identity_matches "$repair_pid" "$repair_start_time" "$helper"; then + for _ in $(seq 1 40); do + ! process_identity_matches "$repair_pid" "$repair_start_time" "$helper" && break + sleep 0.05 + done + if process_identity_matches "$repair_pid" "$repair_start_time" "$helper"; then + kill "$repair_pid" >/dev/null 2>&1 || true + for _ in $(seq 1 20); do + ! process_identity_matches "$repair_pid" "$repair_start_time" "$helper" && break + sleep 0.05 + done + if process_identity_matches "$repair_pid" "$repair_start_time" "$helper"; then + kill -KILL "$repair_pid" >/dev/null 2>&1 || true + fi + fi + fi + fi + if process_identity_matches "$harness_pid" "$harness_start_time"; then + kill "$harness_pid" >/dev/null 2>&1 || true + for _ in $(seq 1 40); do + ! process_identity_matches "$harness_pid" "$harness_start_time" && break + sleep 0.05 + done + if process_identity_matches "$harness_pid" "$harness_start_time"; then + kill -KILL "$harness_pid" >/dev/null 2>&1 || true + fi + fi rm -rf "$fixture_dir" } trap cleanup EXIT @@ -167,6 +217,7 @@ trap cleanup EXIT PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \ PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \ PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \ + PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \ qs -p "$harness" --daemonize >/dev/null for _ in $(seq 1 40); do run ipc show 2>/dev/null | rg -q '^target health-test$' && break @@ -174,6 +225,9 @@ for _ in $(seq 1 40); do done run ipc show 2>/dev/null | rg -q '^target health-test$' || fail 'test IPC target did not start' harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')" +harness_start_time="$(awk '{ print $22 }' "/proc/$harness_pid/stat" 2>/dev/null || true)" +process_identity_matches "$harness_pid" "$harness_start_time" \ + || fail 'could not capture a stable health harness process identity' [[ "$(run ipc call health-test accept "$warning_snapshot" 0)" == "true" ]] \ || fail 'valid warning snapshot was rejected' @@ -230,14 +284,21 @@ jq -e '.busy == false and .generation == ($before + 2) and .queuedRefresh == fal >/dev/null <<<"$state" || fail "queued refresh did not run exactly once: $state" printf 'success\n' >"$repair_mode_file" +rm -f "$repair_started_file" "$repair_release_file" repair_generation="$(jq -r .generation <<<"$state")" [[ "$(run ipc call health-test repair panama.caffeine)" == "true" ]] \ || fail 'repairable check was refused' +for _ in $(seq 1 100); do + [[ -s "$repair_started_file" ]] && break + sleep 0.05 +done +[[ -s "$repair_started_file" ]] || fail 'repair helper never reached the started marker' run ipc call health-test queue >/dev/null working_state="$(run ipc call health-test status)" jq -e '.repairingId == "panama.caffeine" and .queuedRefresh == true and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "warning"' \ >/dev/null <<<"$working_state" || fail "repair did not retain the degraded row while working: $working_state" +: >"$repair_release_file" for _ in $(seq 1 120); do state="$(run ipc call health-test status)" jq -e '.busy == false and .generation == ($before + 1) and .queuedRefresh == false' --argjson before "$repair_generation" \ diff --git a/tests/quickshell/settings-pages-contract.sh b/tests/quickshell/settings-pages-contract.sh index 77bd238..919605e 100755 --- a/tests/quickshell/settings-pages-contract.sh +++ b/tests/quickshell/settings-pages-contract.sh @@ -85,8 +85,13 @@ fi state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)" source_config_path="$repo_dir/config/dot/quickshell" config_path="$state_home/quickshell" +harness="$config_path/settings-pages-harness.qml" test_bin="$state_home/bin" shell_log="$state_home/quickshell.log" +production_config_path="$HOME/.config/quickshell/shell.qml" +harness_pid="" +harness_shell_id="" +production_before="" cleanup_bootstrap() { rm -rf "$state_home" @@ -95,6 +100,21 @@ trap cleanup_bootstrap EXIT mkdir -p "$test_bin" cp -a "$source_config_path" "$config_path" +python3 - "$config_path/shell.qml" "$harness" "$$" <<'PY' +import sys + +source_path, harness_path, identity = sys.argv[1:] +source = open(source_path, encoding="utf-8").read() +needle = "ShellRoot {\n" +replacement = ( + needle + + f' readonly property string settingsPagesHarnessIdentity: "settings-pages-contract-{identity}"\n' +) +if source.count(needle) != 1: + raise SystemExit("shell.qml does not have exactly one ShellRoot") +with open(harness_path, "w", encoding="utf-8") as handle: + handle.write(source.replace(needle, replacement, 1)) +PY cat >"$config_path/scripts/panama-home-assistant" <<'EOF' #!/usr/bin/env bash @@ -138,52 +158,141 @@ EOF chmod +x "$test_bin/flatpak" qs_for_test() { - PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ - qs -p "$config_path" "$@" + if [[ "${1:-}" == "ipc" && "$harness_pid" =~ ^[0-9]+$ ]]; then + PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \ + qs -p "$harness" ipc --pid "$harness_pid" "${@:2}" + else + PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \ + qs -p "$harness" "$@" + fi } -stop_test_shell() { - qs_for_test kill >/dev/null 2>&1 || true - for _ in $(seq 1 80); do - if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \ - && ! qs_for_test ipc show >/dev/null 2>&1; then - return 0 +instances_for_path() { + local expected_path="$1" listing + + listing="$(qs list --all 2>/dev/null)" || return 1 + awk -v expected="$expected_path" ' + /^Instance / { pid = ""; shell_id = "" } + /^[[:space:]]*Process ID:/ { pid = $3 } + /^[[:space:]]*Shell ID:/ { shell_id = $3 } + /^[[:space:]]*Config path:/ { + path = $0 + sub(/^[[:space:]]*Config path: /, "", path) + if (path == expected && pid ~ /^[0-9]+$/ && shell_id != "") + print pid "|" shell_id + } + ' <<<"$listing" +} + +harness_identity_matches() { + local current + + [[ "$harness_pid" =~ ^[0-9]+$ && -n "$harness_shell_id" ]] || return 1 + current="$(instances_for_path "$harness")" || return 1 + grep -Fxq "$harness_pid|$harness_shell_id" <<<"$current" +} + +production_is_preserved() { + local current record pid shell_id + + current="$(instances_for_path "$production_config_path")" || return 1 + while IFS='|' read -r pid shell_id; do + [[ -n "$pid" ]] || continue + kill -0 "$pid" >/dev/null 2>&1 || return 1 + record="$pid|$shell_id" + grep -Fxq "$record" <<<"$current" || return 1 + done <<<"$production_before" +} + +stop_harness() { + local remaining + + if harness_identity_matches; then + kill "$harness_pid" >/dev/null 2>&1 || true + for _ in $(seq 1 80); do + ! kill -0 "$harness_pid" >/dev/null 2>&1 && break + sleep 0.05 + done + if kill -0 "$harness_pid" >/dev/null 2>&1 && harness_identity_matches; then + kill -KILL "$harness_pid" >/dev/null 2>&1 || true + for _ in $(seq 1 20); do + ! kill -0 "$harness_pid" >/dev/null 2>&1 && break + sleep 0.05 + done fi - sleep 0.1 - done - return 1 + fi + remaining="$(instances_for_path "$harness")" || return 1 + harness_pid="" + harness_shell_id="" + [[ -z "$remaining" ]] } cleanup() { - qs_for_test ipc call settings close >/dev/null 2>&1 || true - if stop_test_shell; then + local cleanup_ok=0 + + stop_harness || cleanup_ok=1 + production_is_preserved || cleanup_ok=1 + if (( cleanup_ok == 0 )); then rm -rf "$state_home" else - printf 'settings pages contract: branch shell did not stop; retained %s\n' \ + printf 'settings pages contract: isolated harness cleanup failed; retained %s\n' \ "$state_home" >&2 fi + return "$cleanup_ok" } trap cleanup EXIT start_test_shell() { - stop_test_shell || fail 'pre-existing branch shell did not stop cleanly' + local harness_instances production_pid production_shell_id + + harness_instances="$(instances_for_path "$harness")" \ + || fail 'could not inspect Quickshell instances before starting the runtime harness' + [[ -z "$harness_instances" ]] \ + || fail 'an unexpected process already uses the runtime harness path' for _attempt in 1 2; do qs_for_test --daemonize >"$shell_log" 2>&1 for _ in $(seq 1 80); do - if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then - return - fi - sleep 0.1 + harness_instances="$(instances_for_path "$harness")" \ + || fail 'could not inspect the runtime harness instance' + [[ -n "$harness_instances" ]] && break + sleep 0.05 done - stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly' + if [[ "$(wc -l <<<"$harness_instances")" == 1 && -n "$harness_instances" ]]; then + IFS='|' read -r harness_pid harness_shell_id <<<"$harness_instances" + [[ "$harness_pid" =~ ^[0-9]+$ ]] \ + || fail 'runtime harness did not expose a numeric PID' + + while IFS='|' read -r production_pid production_shell_id; do + [[ -n "$production_pid" ]] || continue + [[ "$harness_shell_id" != "$production_shell_id" ]] || { + stop_harness + fail 'runtime harness shares a Shell ID with production' + } + done <<<"$production_before" + production_is_preserved || { + stop_harness + fail 'production changed before isolated page routing began' + } + + for _ in $(seq 1 80); do + if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then + return + fi + sleep 0.1 + done + fi + stop_harness || fail 'failed runtime harness attempt did not stop cleanly' done sed -n '1,200p' "$shell_log" >&2 fail 'isolated branch shell did not start' } +production_before="$(instances_for_path "$production_config_path")" \ + || fail 'could not list Quickshell instances for the production baseline' +production_is_preserved || fail 'could not capture a stable production instance set' start_test_shell qs_for_test ipc call home-assistant fixture ready >/dev/null -shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')" +shell_pid="$harness_pid" [[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process' pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about) @@ -218,6 +327,8 @@ intelligence_desktop_file="$HOME/.local/share/applications/panama-screen-intelli desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen Intelligence desktop entry is invalid' trap - EXIT -cleanup +cleanup || fail 'runtime harness did not stop without disturbing production' [[ ! -e "$state_home" ]] || fail 'temporary Settings state was not removed after shell exit' -printf 'settings pages contract: PASS\n' +production_pids="$(cut -d'|' -f1 <<<"$production_before" | paste -sd, -)" +[[ -n "$production_pids" ]] || production_pids="none" +printf 'settings pages contract: PASS (production PIDs preserved: %s)\n' "$production_pids" From 08b16fa03f4bd60d820b56246f218968a93b0a50 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 12:25:37 -0400 Subject: [PATCH 5/5] Fix live network and phone discovery --- .../dot/quickshell/scripts/panama-kdeconnect | 105 +++++++++++++- .../dot/quickshell/services/Connectivity.qml | 9 +- tests/quickshell/kdeconnect_bridge_test.py | 128 ++++++++++++++++++ 3 files changed, 235 insertions(+), 7 deletions(-) diff --git a/config/dot/quickshell/scripts/panama-kdeconnect b/config/dot/quickshell/scripts/panama-kdeconnect index fbbf5f5..5e670df 100755 --- a/config/dot/quickshell/scripts/panama-kdeconnect +++ b/config/dot/quickshell/scripts/panama-kdeconnect @@ -25,6 +25,9 @@ PLUGIN_ACTIONS = { "kdeconnect_share": "share", } DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices" +DEVICE_OBJECT_LINE = re.compile( + rf"(?P{re.escape(DEVICE_OBJECT_PREFIX)}/(?P[A-Fa-f0-9]{{32,64}}))$" +) Runner = Callable[..., subprocess.CompletedProcess[str]] @@ -107,6 +110,13 @@ def parse_string_property(output: str) -> str: return parts[1] if len(parts) == 2 and parts[0] == "s" else "" +def parse_bool_property(output: str) -> bool | None: + parts = output.split() + if len(parts) != 2 or parts[0] != "b" or parts[1] not in {"true", "false"}: + return None + return parts[1] == "true" + + def run_command( command: list[str], *, @@ -179,6 +189,82 @@ def reported_type(device_id: str, runner: Runner = subprocess.run) -> str: return parse_string_property(result.stdout) if result.returncode == 0 else "" +def device_property( + device_id: str, + member: str, + runner: Runner = subprocess.run, +) -> subprocess.CompletedProcess[str]: + return run_command( + [ + "busctl", + "--user", + "get-property", + "org.kde.kdeconnect", + device_object(device_id), + "org.kde.kdeconnect.device", + member, + ], + runner=runner, + ) + + +def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]: + try: + result = run_command( + ["busctl", "--user", "tree", "org.kde.kdeconnect"], + runner=runner, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + if result.returncode != 0: + return [] + return [ + match.group("id") + for line in result.stdout.splitlines() + if (match := DEVICE_OBJECT_LINE.search(line.strip())) is not None + ] + + +def dbus_devices(runner: Runner = subprocess.run) -> list[dict[str, object]]: + devices: list[dict[str, object]] = [] + for device_id in dbus_device_ids(runner): + try: + name_result = device_property(device_id, "name", runner) + type_result = device_property(device_id, "type", runner) + paired_result = device_property(device_id, "isPaired", runner) + reachable_result = device_property(device_id, "isReachable", runner) + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + name = parse_string_property(name_result.stdout) if name_result.returncode == 0 else "" + device_type = parse_string_property(type_result.stdout) if type_result.returncode == 0 else "" + paired = parse_bool_property(paired_result.stdout) if paired_result.returncode == 0 else None + reachable = parse_bool_property(reachable_result.stdout) if reachable_result.returncode == 0 else None + if not name or paired is not True or reachable is None: + continue + try: + plugins = device_plugins(device_id, runner) + except (FileNotFoundError, subprocess.TimeoutExpired): + plugins = [] + actions = sorted( + { + action + for plugin, action in PLUGIN_ACTIONS.items() + if plugin in plugins + } + ) + devices.append( + { + "id": device_id, + "name": name, + "type": device_type or inferred_type(name), + "paired": paired, + "reachable": reachable, + "actions": actions, + } + ) + return devices + + def collect_status(runner: Runner = subprocess.run) -> dict[str, object]: try: listing = run_command( @@ -200,15 +286,24 @@ def collect_status(runner: Runner = subprocess.run) -> dict[str, object]: continue device_id = match.group("id") try: - device = normalize_device_line( - line, - device_plugins(device_id, runner), - reported_type(device_id, runner), - ) + plugins = device_plugins(device_id, runner) + device_type = reported_type(device_id, runner) + except (FileNotFoundError, subprocess.TimeoutExpired): + plugins = [] + device_type = "" + try: + device = normalize_device_line(line, plugins, device_type) except ValueError: continue devices.append(device) + known_ids = {str(device["id"]) for device in devices} + devices.extend( + device + for device in dbus_devices(runner) + if str(device["id"]) not in known_ids + ) + devices.sort( key=lambda device: ( not bool(device["reachable"]), diff --git a/config/dot/quickshell/services/Connectivity.qml b/config/dot/quickshell/services/Connectivity.qml index b14fd8a..fc63542 100644 --- a/config/dot/quickshell/services/Connectivity.qml +++ b/config/dot/quickshell/services/Connectivity.qml @@ -33,11 +33,16 @@ Singleton { } readonly property var wiredDevice: { + let fallback = null; for (const device of Networking.devices.values) { - if (device.type === DeviceType.Wired) + if (device.type !== DeviceType.Wired) + continue; + if (device.connected) return device; + if (!fallback) + fallback = device; } - return null; + return fallback; } readonly property var adapter: Bluetooth.defaultAdapter diff --git a/tests/quickshell/kdeconnect_bridge_test.py b/tests/quickshell/kdeconnect_bridge_test.py index 2981450..41d0d73 100644 --- a/tests/quickshell/kdeconnect_bridge_test.py +++ b/tests/quickshell/kdeconnect_bridge_test.py @@ -130,6 +130,134 @@ class KdeConnectBridgeTest(unittest.TestCase): ], ) + def test_status_falls_back_to_paired_dbus_device_when_cli_is_empty(self) -> None: + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + device_path = f"/modules/kdeconnect/devices/{device_id}" + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + if command == ["kdeconnect-cli", "--list-devices"]: + return subprocess.CompletedProcess(command, 0, "0 devices found\n", "") + if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]: + return subprocess.CompletedProcess(command, 0, f"└─ {device_path}\n", "") + if command[:3] == ["busctl", "--user", "call"]: + return subprocess.CompletedProcess(command, 0, "as 0\n", "") + if command[:3] == ["busctl", "--user", "get-property"]: + values = { + "name": 's "Fixture iPhone"\n', + "type": 's "phone"\n', + "isPaired": "b true\n", + "isReachable": "b false\n", + "supportedPlugins": ( + 'as 3 "kdeconnect_share" "kdeconnect_clipboard" ' + '"kdeconnect_findmyphone"\n' + ), + } + return subprocess.CompletedProcess(command, 0, values[command[-1]], "") + raise AssertionError(command) + + self.assertEqual( + bridge.collect_status(runner), + { + "available": True, + "devices": [ + { + "id": device_id, + "name": "Fixture iPhone", + "type": "phone", + "paired": True, + "reachable": False, + "actions": ["clipboard", "ring", "share"], + } + ], + "error": "", + }, + ) + + def test_dbus_plugin_timeout_keeps_device_with_no_actions(self) -> None: + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]: + path = f"/modules/kdeconnect/devices/{device_id}" + return subprocess.CompletedProcess(command, 0, f"└─ {path}\n", "") + if command[:3] == ["busctl", "--user", "call"]: + return subprocess.CompletedProcess(command, 0, "as 0\n", "") + if command[:3] == ["busctl", "--user", "get-property"]: + values = { + "name": 's "Fixture iPhone"\n', + "type": 's "phone"\n', + "isPaired": "b true\n", + "isReachable": "b false\n", + } + if command[-1] == "supportedPlugins": + raise subprocess.TimeoutExpired(command, 8) + return subprocess.CompletedProcess(command, 0, values[command[-1]], "") + raise AssertionError(command) + + self.assertEqual(bridge.dbus_devices(runner)[0]["actions"], []) + + def test_cli_device_plugin_timeout_fails_closed(self) -> None: + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + listing = f"- Fixture iPhone: {device_id} (paired and reachable)\n" + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + if command == ["kdeconnect-cli", "--list-devices"]: + return subprocess.CompletedProcess(command, 0, listing, "") + raise subprocess.TimeoutExpired(command, 8) + + status = bridge.collect_status(runner) + + self.assertEqual(status["devices"][0]["type"], "phone") + self.assertEqual(status["devices"][0]["actions"], []) + + def test_dbus_inventory_excludes_unpaired_peers(self) -> None: + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]: + path = f"/modules/kdeconnect/devices/{device_id}" + return subprocess.CompletedProcess(command, 0, f"└─ {path}\n", "") + if command[:3] == ["busctl", "--user", "get-property"]: + values = { + "name": 's "Nearby Stranger"\n', + "type": 's "phone"\n', + "isPaired": "b false\n", + "isReachable": "b true\n", + } + return subprocess.CompletedProcess(command, 0, values[command[-1]], "") + raise AssertionError(command) + + self.assertEqual(bridge.dbus_devices(runner), []) + + def test_status_merges_paired_dbus_device_missing_from_cli(self) -> None: + cli_id = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + dbus_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + listing = f"- Fixture Laptop: {cli_id} (paired and reachable)\n" + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + if command == ["kdeconnect-cli", "--list-devices"]: + return subprocess.CompletedProcess(command, 0, listing, "") + if command == ["busctl", "--user", "tree", "org.kde.kdeconnect"]: + path = f"/modules/kdeconnect/devices/{dbus_id}" + return subprocess.CompletedProcess(command, 0, f"└─ {path}\n", "") + if command[:3] == ["busctl", "--user", "call"]: + return subprocess.CompletedProcess(command, 0, "as 0\n", "") + if command[:3] == ["busctl", "--user", "get-property"]: + is_dbus_device = dbus_id in command[4] + values = { + "name": 's "Fixture iPhone"\n', + "type": 's "phone"\n' if is_dbus_device else 's "desktop"\n', + "isPaired": "b true\n", + "isReachable": "b false\n", + "supportedPlugins": "as 0\n", + } + return subprocess.CompletedProcess(command, 0, values[command[-1]], "") + raise AssertionError(command) + + status = bridge.collect_status(runner) + + self.assertEqual({device["id"] for device in status["devices"]}, {cli_id, dbus_id}) + if __name__ == "__main__": unittest.main()