diff --git a/ansible.cfg b/ansible.cfg index 8d978b9c..e26deb33 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,6 +1,7 @@ [defaults] inventory = inventory/hosts.yml roles_path = roles +library = image/library callback_plugins = plugins/callback interpreter_python = auto_silent retry_files_enabled = False diff --git a/assets/scripts/cybexos-runtime b/assets/scripts/cybexos-runtime index fd690c8f..ea094191 100755 --- a/assets/scripts/cybexos-runtime +++ b/assets/scripts/cybexos-runtime @@ -17,6 +17,7 @@ Usage: cybexos-runtime dev enable CHECKOUT cybexos-runtime dev disable cybexos-runtime dev status + cybexos-runtime shell status|safe|recover cybexos-runtime plugin add|update|clone|remove|list|enable|disable|set|reload|restart [arguments] The development switch only records a validated checkout. It never fetches, @@ -243,6 +244,9 @@ exec_runtime() { export CYBEXOS_PLUGIN_ROOT=$data_root/plugins export OMARCHY_PATH=$selected/compat/omarchy export PATH=$OMARCHY_PATH/bin:$PATH + if [[ -f $selected/scripts/shell-recovery.py ]]; then + exec python3 "$selected/scripts/shell-recovery.py" run "$selected" + fi exec /usr/bin/qs -p "$selected" ;; hypridle|hyprlock) @@ -280,6 +284,9 @@ exec_runtime() { ipc_call() { local selected selected=$(runtime_path quickshell) + if [[ -f $selected/scripts/shell-recovery.py ]]; then + selected=$(python3 "$selected/scripts/shell-recovery.py" path "$selected") + fi # --any-display: a caller started by systemd (a reminder timer) need not # carry the session's display. The -- keeps function names that shadow qs # subcommands (e.g. show) positional. @@ -303,6 +310,21 @@ case $command_name in ipc_call "$@" ;; dev) dev_command "$@" ;; + shell) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + case $1 in status|safe|recover|record-stop) ;; *) usage >&2; exit 2 ;; esac + selected=$(runtime_path quickshell) + # A previous release without recovery support remains rollback-compatible. + [[ -f $selected/scripts/shell-recovery.py ]] || { + [[ $1 != record-stop ]] || exit 0 + printf 'cybexos-runtime: this desktop release has no recovery mode\n' >&2 + exit 1 + } + python3 "$selected/scripts/shell-recovery.py" "$1" "$selected" + if [[ $1 == safe || $1 == recover ]]; then + exec systemctl --user restart quickshell.service + fi + ;; plugin) selected=$(runtime_path quickshell) if [[ ${1:-} == reload ]]; then diff --git a/assets/scripts/cybexos-update-run b/assets/scripts/cybexos-update-run index 5ee4fbf6..f8d9d7bf 100755 --- a/assets/scripts/cybexos-update-run +++ b/assets/scripts/cybexos-update-run @@ -311,6 +311,8 @@ write_status() { --argjson flatpakRc "$flatpak_rc" --argjson testsRc "$tests_rc" \ --argjson ansibleRc "$ansible_rc" --arg snapshotId "${snapshot_id:-}" \ --argjson mixedState "${mixed_state:-false}" \ + --arg transactionProtection "${transaction_protection:-unavailable}" \ + --arg rollbackState "${rollback_state:-none}" \ --argjson firmware "${with_firmware:-false}" \ --argjson firmwareDone "${firmware_done:-false}" \ --argjson firmwareRc "${firmware_rc:-0}" \ @@ -324,6 +326,7 @@ write_status() { firmwareRc: $firmwareRc, firmwareReboot: $firmwareReboot, snapshotId: $snapshotId, bootId: $bootId, releaseVersion: $releaseVersion, mixedState: $mixedState, + transactionProtection: $transactionProtection, rollbackState: $rollbackState, deferredCancel: true, rebootRecommendation: $rebootRecommendation}' > "$temporary" mv -f -- "$temporary" "$run_dir/status.json" @@ -562,10 +565,25 @@ tagged_command() { set +e stdbuf -oL -eL "$@" 2>&1 | tee "$logfile" \ | sed -u "s/^/[$tag] /" >> "$run_dir/run.log" - printf '%s\n' "${PIPESTATUS[0]}" > "$rcfile" + local -a result=("${PIPESTATUS[@]}") + local code=${result[0]} + if ((result[1] != 0 || result[2] != 0)); then code=125; fi + printf '%s\n' "$code" > "$rcfile" || return 125 return 0 } +phase_result() { + local value='' + if [[ -r $1 ]] && IFS= read -r value <"$1" \ + && [[ $value =~ ^[0-9]{1,3}$ ]] && ((10#$value <= 255)); then + printf '%s\n' "$((10#$value))" + else + # An empty/missing result must never mean success (Bash arithmetic treats + # the empty string as zero), especially when log storage filled up. + printf '125\n' + fi +} + worker_finalized=false release_config_changed=false release_activated=false @@ -573,6 +591,11 @@ release_backup="" cancel_requested=false packages_ran=false mixed_state=false +transaction_started=false +transaction_applying=false +transaction_helper="" +transaction_protection=unavailable +rollback_state=none firmware_done=false firmware_rc=0 firmware_reboot=false @@ -656,6 +679,36 @@ restore_release_configuration() { release_config_changed=false } +# Files on /home are outside the root recovery point. The shared helper owns +# a manifest of vendor-only integration paths and journals its checkpoint in +# the top-level Btrfs store. Settings, plugins and personal data never rewind. +finish_failed_transaction() { + [[ $transaction_started == true ]] || return 0 + local action=abort + [[ $transaction_applying != true ]] || action=rollback + local journal journal_state + journal=$("${privileged_package[@]}" "$transaction_helper" status "$run_id" 2>/dev/null) || journal='' + journal_state=$(jq -r '.state // empty' <<<"$journal" 2>/dev/null) || journal_state='' + case $journal_state in + prepared) action=abort ;; + applying|validating|rolling-back|rollback-failed|awaiting-desktop) action=rollback ;; + committed|aborted) transaction_started=false; return 0 ;; + esac + if "${privileged_package[@]}" "$transaction_helper" "$action" "$run_id" \ + >>"$run_dir/run.log" 2>&1; then + transaction_started=false + if [[ $action == rollback ]]; then + rollback_state=restart-required + reboot_recommendation=recommended + # The currently mounted root remains the failed generation until boot. + mixed_state=true + fi + else + rollback_state=failed + return 1 + fi +} + worker_failed() { local phase=$1 message=$2 rc=$3 # A capsule staged before a later step failed still waits for a restart. @@ -665,6 +718,13 @@ worker_failed() { message="$message; restoring the previous installer configuration also failed" rc=125 fi + if ! finish_failed_transaction; then + phase=rollback + message="$message; automatic recovery failed; use the recovery boot menu" + rc=125 + elif [[ $rollback_state == restart-required ]]; then + message="$message; the previous system and vendor desktop were restored; restart to use them" + fi write_status failed "$phase" "$message" "$rc" worker_finalized=true exit "$rc" @@ -712,12 +772,11 @@ run_as_update_owner() { activate_release() { [[ $release_transaction == true ]] || return 0 - local releases_root="$release_data_root/releases" expected old_release old_path + local releases_root="$release_data_root/releases" expected local current_link="$release_data_root/current" previous_target="" local next_link="$release_data_root/.current.$run_id.new" local restore_link="$release_data_root/.current.$run_id.restore" local previous_present=false - local -a installed_releases=() expected=$(readlink -f -- "$releases_root/$release_version") || return 1 [[ $expected == "$repo" ]] || return 1 @@ -755,6 +814,13 @@ activate_release() { printf '[release] activated CybexOS %s\n' "$release_version" \ >>"$run_dir/run.log" +} + +prune_releases() { + [[ $release_transaction == true ]] || return 0 + local releases_root="$release_data_root/releases" old_release old_path + local -a installed_releases=() + mapfile -t installed_releases < <( find "$releases_root" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \ | "$repo/scripts/semver" sort @@ -801,6 +867,12 @@ worker_cancelled() { message="Update cancelled, but restoring the previous installer configuration failed" rc=125 fi + if ! finish_failed_transaction; then + state=failed; phase=rollback; rc=125 + message="$message; automatic recovery failed" + elif [[ $rollback_state == restart-required ]]; then + message="$message; previous system selected; restart required" + fi write_status "$state" "$phase" "$message" "$rc" worker_finalized=true exit "$rc" @@ -817,6 +889,7 @@ worker_exit_fallback() { if ! restore_release_configuration; then message="$message; restoring the previous installer configuration also failed" fi + finish_failed_transaction || message="$message; automatic recovery failed" write_status failed internal "$message" \ "$([[ $rc -eq 0 ]] && printf 125 || printf '%s' "$rc")" 2>/dev/null || true } @@ -907,7 +980,7 @@ run_firmware_phase() { if [[ -x $helper ]]; then tagged_command firmware "$run_dir/firmware.log" "$run_dir/firmware.rc" \ "${privileged_package[@]}" "$helper" install --events "$events" - firmware_rc=$(<"$run_dir/firmware.rc") + firmware_rc=$(phase_result "$run_dir/firmware.rc") else firmware_rc=127 printf '%s\n' "$firmware_rc" >"$run_dir/firmware.rc" @@ -1000,8 +1073,20 @@ worker() { if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 \ && -n ${CYBEXOS_UPDATE_TEST_LIBEXEC:-} ]]; then snapshot_helper_root=$CYBEXOS_UPDATE_TEST_LIBEXEC + elif [[ ! -x $snapshot_helper_root/cybexos-system-snapshot \ + && ! -x $snapshot_helper_root/xps-system-snapshot ]]; then + # The RPM and checkout share a protocol, but own different libexec paths. + local alternate_helper_root + for alternate_helper_root in /usr/libexec /usr/local/libexec; do + if [[ -x $alternate_helper_root/cybexos-system-snapshot \ + || -x $alternate_helper_root/xps-system-snapshot ]]; then + snapshot_helper_root=$alternate_helper_root + break + fi + done fi local snapshot_helper="$snapshot_helper_root/cybexos-system-snapshot" + transaction_helper="$snapshot_helper_root/cybexos-update-transaction" local legacy_snapshot_helper="$snapshot_helper_root/xps-system-snapshot" local snapshot_rc=0 # Existing installations can receive the renamed updater before Ansible has @@ -1013,20 +1098,97 @@ worker() { if [[ -x $snapshot_helper ]]; then tagged_command snapshot "$run_dir/snapshot.log" "$run_dir/snapshot.rc" \ "${privileged_package[@]}" "$snapshot_helper" create "update $run_id" - snapshot_rc=$(<"$run_dir/snapshot.rc") + snapshot_rc=$(phase_result "$run_dir/snapshot.rc") if ((snapshot_rc != 0)); then worker_failed snapshot "Could not create the pre-update recovery point" "$snapshot_rc" fi - snapshot_id=$(tail -n 1 "$run_dir/snapshot.log") - [[ $snapshot_id == skipped:* ]] && snapshot_id="" + snapshot_id=$(tail -n 1 "$run_dir/snapshot.log") || snapshot_id='' + if [[ $snapshot_id == skipped:* ]]; then + local skipped_filesystem + skipped_filesystem=$(findmnt --noheadings --output FSTYPE --target / 2>/dev/null | xargs) || skipped_filesystem='' + if [[ -z $skipped_filesystem || $skipped_filesystem == btrfs ]]; then + worker_failed snapshot "The recovery helper skipped a Btrfs or unknown filesystem" 125 + fi + snapshot_id='' + elif [[ ! $snapshot_id =~ ^[0-9]{8}T[0-9]{6}Z-[0-9]+$ ]]; then + worker_failed snapshot "The recovery helper did not return a valid recovery point" 125 + fi else - if [[ $(findmnt --noheadings --output FSTYPE --target / 2>/dev/null | xargs) == btrfs ]]; then + local root_filesystem + root_filesystem=$(findmnt --noheadings --output FSTYPE --target / 2>/dev/null | xargs) || root_filesystem='' + if [[ -z $root_filesystem || $root_filesystem == btrfs ]]; then worker_failed snapshot "Btrfs recovery helper is not installed" 127 fi printf '[snapshot] non-Btrfs root; no filesystem recovery point created\n' \ >>"$run_dir/run.log" fi + local bootstrap_helper='' bootstrap_source='' bootstrap_bundle='' + if [[ -n $snapshot_id ]]; then + # An explicitly selected full checkout/release has already passed the + # worker's source validation. A package-only source invocation may use + # its own adjacent checkout, never an arbitrary CWD or home directory. + local adjacent_source + adjacent_source=$(dirname -- "$(dirname -- "$(dirname -- "$script_path")")") + if [[ $full == true && -x $repo/roles/base/files/cybexos-update-bootstrap ]]; then + bootstrap_source=$repo/roles/base/files + elif [[ -x $snapshot_helper_root/cybexos-update-bootstrap ]]; then + bootstrap_source=$snapshot_helper_root + elif [[ ${CYBEXOS_UPDATE_TESTING:-0} != 1 \ + && $script_path == "$adjacent_source/assets/scripts/cybexos-update-run" \ + && -f $adjacent_source/site.yml && -x $adjacent_source/tests/run \ + && -x $adjacent_source/roles/base/files/cybexos-update-bootstrap ]]; then + bootstrap_source=$adjacent_source/roles/base/files + fi + bootstrap_helper=${bootstrap_source:+$bootstrap_source/cybexos-update-bootstrap} + if [[ -z $bootstrap_helper ]]; then + worker_failed snapshot "Recovery bootstrap is unavailable; update from a complete CybexOS release or checkout" 127 + fi + if ! "${privileged_package[@]}" /usr/bin/python3 -I "$bootstrap_helper" ready \ + --libexec "$snapshot_helper_root" >>"$run_dir/run.log" 2>&1; then + # The first point preserves the untouched old system. The second point + # includes boot recovery, which must survive exchanging the root before + # home restoration finishes. No packages or vendor home paths change + # until this hook is durable and the second checkpoint succeeds. + write_status running snapshot "Installing recovery for the first transactional update" 0 + tagged_command bootstrap "$run_dir/bootstrap.log" "$run_dir/bootstrap.rc" \ + "${privileged_package[@]}" /usr/bin/python3 -I "$bootstrap_helper" prepare \ + --source "$bootstrap_source" --checkpoint "$snapshot_id" --id "$run_id" + local bootstrap_rc protected_snapshot + bootstrap_rc=$(phase_result "$run_dir/bootstrap.rc") + ((bootstrap_rc == 0)) || worker_failed snapshot "Could not prepare durable boot recovery; no packages were changed" "$bootstrap_rc" + protected_snapshot=$(tail -n 1 "$run_dir/bootstrap.log" | jq -er '.snapshot | strings') || protected_snapshot='' + transaction_helper=$(tail -n 1 "$run_dir/bootstrap.log" | jq -er '.transactionHelper | strings') || transaction_helper='' + if [[ ! $protected_snapshot =~ ^[0-9]{8}T[0-9]{6}Z-[0-9]+$ \ + || $protected_snapshot == "$snapshot_id" || ! -x $transaction_helper ]]; then + worker_failed snapshot "Recovery bootstrap did not return a protected checkpoint" 125 + fi + snapshot_id=$protected_snapshot + bootstrap_bundle=$(dirname -- "$transaction_helper") + bootstrap_helper=$bootstrap_bundle/cybexos-update-bootstrap + fi + if ! "${privileged_package[@]}" "$transaction_helper" begin "$run_id" "$snapshot_id" \ + --uid "${CYBEXOS_UPDATE_OWNER_UID:-$UID}" >>"$run_dir/run.log" 2>&1; then + worker_failed snapshot "Could not checkpoint the vendor desktop; no packages were changed" 1 + fi + transaction_started=true + transaction_protection=btrfs + fi honor_cancellation + if [[ $transaction_started == true && $with_packages == true ]]; then + write_status running download "Downloading packages before changing the installed system" 0 + tagged_command download "$run_dir/download.log" "$run_dir/download.rc" \ + "${privileged_package[@]}" dnf -y upgrade --refresh --downloadonly + local download_rc + download_rc=$(phase_result "$run_dir/download.rc") + ((download_rc == 0)) || worker_failed download "Package download failed; installed system is unchanged" "$download_rc" + honor_cancellation + fi + if [[ $transaction_started == true ]]; then + "${privileged_package[@]}" "$transaction_helper" applying "$run_id" \ + >>"$run_dir/run.log" 2>&1 \ + || worker_failed snapshot "Could not journal the update transaction" 1 + transaction_applying=true + fi local dnf_pid="" flatpak_pid="" if [[ $with_packages == true ]]; then write_status running packages "Updating system packages and Flatpaks" 0 @@ -1041,12 +1203,12 @@ worker() { printf '0\n' > "$run_dir/flatpak.rc"; flatpak_done=true fi wait_for_job "$dnf_pid" - dnf_rc=$(<"$run_dir/dnf.rc"); dnf_done=true + dnf_rc=$(phase_result "$run_dir/dnf.rc"); dnf_done=true packages_ran=true write_status running packages "System package update finished" 0 if [[ -n $flatpak_pid ]]; then wait_for_job "$flatpak_pid" - flatpak_rc=$(<"$run_dir/flatpak.rc"); flatpak_done=true + flatpak_rc=$(phase_result "$run_dir/flatpak.rc"); flatpak_done=true write_status running packages "Package phase finished" 0 fi else @@ -1058,12 +1220,10 @@ worker() { if (( dnf_rc != 0 )); then worker_failed packages "dnf exited with status $dnf_rc" "$dnf_rc" fi - honor_cancellation - - if [[ $with_firmware == true ]]; then - run_firmware_phase - honor_cancellation + if [[ $transaction_started == true ]] && ((flatpak_rc != 0)); then + worker_failed packages "Flatpak exited with status $flatpak_rc" "$flatpak_rc" fi + honor_cancellation if [[ $full == true ]]; then cd -- "$repo" || { @@ -1076,7 +1236,7 @@ worker() { # A wedged check must not hold the update lock indefinitely. tagged_command tests "$run_dir/tests.log" "$run_dir/tests.rc" \ timeout --kill-after=1m 30m ./tests/run - tests_rc=$(<"$run_dir/tests.rc") + tests_rc=$(phase_result "$run_dir/tests.rc") if (( tests_rc == 124 )); then worker_failed tests "Repository checks did not finish within 30 minutes" "$tests_rc" elif (( tests_rc != 0 )); then @@ -1098,9 +1258,13 @@ worker() { [[ $release_transaction != true ]] || mixed_state=true write_status running ansible "Applying the managed configuration" 0 # Keep the complete stream even when the configured callback is compact. + # Ansible's DNF modules inherit the process mask just like a direct dnf + # invocation. Keep shared package state readable without relaxing the + # updater's private log/state mask. tagged_command ansible "$run_dir/ansible.log" "$run_dir/ansible.rc" \ + sh -c 'umask 022; exec "$@"' cybexos-update-ansible \ ansible-playbook site.yml --skip-tags boot "${ansible_args[@]}" - ansible_rc=$(<"$run_dir/ansible.rc") + ansible_rc=$(phase_result "$run_dir/ansible.rc") if (( ansible_rc != 0 )); then worker_failed ansible "Ansible exited with status $ansible_rc" "$ansible_rc" fi @@ -1113,9 +1277,65 @@ worker() { fi fi + # RPM posttrans only queues reconciliation. Finish it within the protected + # update before declaring the new installed policy healthy. + local reconcile_helper=/usr/libexec/cybexos-reconcile + if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 \ + && -n ${CYBEXOS_UPDATE_TEST_LIBEXEC:-} ]]; then + reconcile_helper="$CYBEXOS_UPDATE_TEST_LIBEXEC/cybexos-reconcile" + fi + if [[ -x $reconcile_helper ]]; then + write_status running reconcile "Applying the updated installed-system policy" 0 + tagged_command reconcile "$run_dir/reconcile.log" "$run_dir/reconcile.rc" \ + "${privileged_package[@]}" "$reconcile_helper" --retry + local reconcile_rc reconcile_status + reconcile_rc=$(phase_result "$run_dir/reconcile.rc") + ((reconcile_rc == 0)) || worker_failed reconcile "Installed-system reconciliation failed" "$reconcile_rc" + # The helper deliberately exits successfully when another reconciler has + # its lock or RPM is busy. Success alone does not mean the new policy was + # applied; require its durable status for the current packaged version. + if ! reconcile_status=$("${privileged_package[@]}" "$reconcile_helper" --status \ + 2>>"$run_dir/reconcile.log") \ + || ! jq -e --argjson ownerUid "${CYBEXOS_UPDATE_OWNER_UID:-$UID}" ' + .desiredVersion as $desired | .accounts as $accounts | + .state == "ready" and .pending == false + and ($desired | type == "string" and length > 0) + and .version == $desired + and (.accounts | type == "object" and length > 0 + and all(.[]; .state == "ready" and .version == $desired)) + and ($ownerUid < 1000 or any($accounts[]; .uid == $ownerUid))' \ + <<<"$reconcile_status" >/dev/null 2>&1; then + worker_failed reconcile "Installed-system reconciliation has not reached the current ready state" 125 + fi + fi + check_reboot_recommendation apply_firmware_reboot_recommendation + if [[ $transaction_started == true ]]; then + write_status running health "Validating the updated system and desktop" 0 + if ! "${privileged_package[@]}" "$transaction_helper" commit "$run_id" \ + >>"$run_dir/run.log" 2>&1; then + worker_failed health "The installed health check failed" 1 + fi + transaction_started=false + if [[ -n $bootstrap_bundle ]]; then + # A source role or RPM now owns the canonical recovery service. Retire + # the temporary implementation only after the journal is committed. + "${privileged_package[@]}" /usr/bin/python3 -I "$bootstrap_helper" finalize \ + --bundle "$bootstrap_bundle" >>"$run_dir/run.log" 2>&1 \ + || printf '[snapshot] retained recovery bootstrap for a later update\n' >>"$run_dir/run.log" + fi + fi + prune_releases + # Device firmware is not part of a filesystem snapshot. Apply it only after + # reversible system/configuration work has passed its health gate. + if [[ $with_firmware == true ]]; then + run_firmware_phase + honor_cancellation + apply_firmware_reboot_recommendation + fi + local message="Update completed" (( flatpak_rc == 0 )) || message="Update completed; Flatpak reported status $flatpak_rc" (( firmware_rc == 0 )) || message="$message; firmware reported status $firmware_rc" diff --git a/docs/architecture/ownership.md b/docs/architecture/ownership.md index dff53810..037cbc21 100644 --- a/docs/architecture/ownership.md +++ b/docs/architecture/ownership.md @@ -57,6 +57,16 @@ installed runtime. The command records only the canonical path and reloads managed desktop components; it never fetches, resets, merges, commits, or writes inside the checkout. +The Hyprland startup hook must also work when the development checkout is +selected on an ISO installation. Checkout installs place the ordered session +starter in `/usr/local/libexec`; ISO/RPM installs place it in `/usr/libexec`. +`autostart.lua` resolves an executable helper at login, including when the +checkout is loaded verbatim without the image packager's path rewriting. +Otherwise Hyprland can start with `hyprland-session.target` inactive, leaving +Quickshell, wallpaper, idle handling and desktop portals unavailable. The +session startup fixtures exercise checkout, packaged and ISO development +layouts in both source gates. + Use `cybex dev status` to show the active source and `cybex dev disable` to return to the verified vendor runtime. Internet updates continue to stage and activate releases while development mode is on; they do not modify the selected @@ -90,23 +100,43 @@ preserve unknown fields, retain a recoverable original, and avoid downgrading data on rollback. A new API or schema needs a compatibility plan and upgrade/rollback fixtures before it is released. -That target is not yet enforced for every application. Remaining work: - -- Shell settings currently normalize to known keys and have visual migrations - that infer an untouched value from equality with a previous default. Replace - that inference with explicit override tracking; treat legacy stored choices - conservatively. Keep unsupported future schemas read-only on older hosts. -- Personal-dotfile deployment still replaces files such as Fastfetch, - Voxtype, MIME associations, and XDG user directories. Move defaults into - vendor fragments where supported, or seed only absent user files. Migrate - adopted files using a last-installed baseline and preserve conflicting edits. -- Includes need application-specific precedence tests. Git and Kitty commonly - use later values; SSH commonly uses the first obtained value. The current - SSH include at the beginning can take precedence over personal choices. -- Extend release checks beyond file sentinels: verify settings behavior, an - enabled API fixture, service overrides, app defaults, failed updates, and - rollback against supported previous releases. Preserve user-created package - and service additions when optional distro features change. - -Until those changes land, the widget contract does not imply that every -existing application setting already survives distro convergence unchanged. +Shell settings now use a sparse schema-27 document: the presence of a known +key records an explicit choice, including a choice equal to the current +default. Reset removes the override; Undo restores its ownership as well as +its value. Legacy stored values are conservatively treated as explicit. Visual +redesigns no longer infer an untouched preference from equality with an old +default. Unknown JSON fields survive edits and resets, and a newer schema is +read-only on an older shell. + +The asynchronous settings writer merges independent external edits, rejects +conflicting writes, and confirms fsync and atomic publication before reporting +success. A rejected edit is retained in a `shell.json.conflict-*` sidecar before +the form reloads the external values. The first schema migration retains the +exact original in `shell.json.before-migration-*`. These files live beside the +user's settings and are retained for recovery; the shell never prunes them. +The production Qt document component has real-engine lifecycle tests for +queued changes, retries and unknown data, alongside filesystem transaction tests. + +Personal application stores (Fastfetch, Voxtype, Oh My Posh, MIME associations, +XDG directories and npm configuration) are seeded only when absent. Shared +Fish, Kitty, Git and SSH fragments use the same ownership ledger on checkout +and ISO paths. A fragment advances only if it still matches its last installed +bytes; conflicting edits, symlinks and explicit deletions survive. Adopted +bytes are backed up before replacement. Unknown legacy fragments remain +user-owned instead of being guessed at from their filename. + +Managed includes follow each application's precedence: Git/Kitty defaults +come first, while SSH fallbacks come last in an explicit `Host *` scope. +Git credential helpers accumulate instead of overriding, so vendor credential +helpers are omitted when personal configuration provides its own chain. The +SSH vendor fragment lives outside `.ssh/config.d` so wildcard includes cannot +accidentally give it priority over a personal host. Moving an old unedited +include retains its original file; edited include blocks are left intact. + +Remaining release coverage should exercise service overrides, optional package +and service additions, and supported previous releases across failure and +rollback, beyond file sentinels. Application ownership is scoped to these +managed fragments; independent application databases remain the application's +responsibility. + +The widget contract does not imply ownership of unrelated application state. diff --git a/docs/fedora-major-upgrade.md b/docs/fedora-major-upgrade.md index b8d2f162..28c6960e 100644 --- a/docs/fedora-major-upgrade.md +++ b/docs/fedora-major-upgrade.md @@ -1,5 +1,107 @@ # Fedora major-upgrade runbook +## Guided upgrade workflow + +`cybex upgrade-system` now coordinates preflight, a durable background DNF5 +**download**, an explicit offline reboot, target convergence and rollback. It +requires a separately reviewed target release; the current source manifest +supports **Fedora 44 only**. This workflow does not qualify or advertise Fedora +45. Incrementing a release number is insufficient. + +Select either a clean reviewed target checkout/release directory whose +`release-manifest.json` **and** inventory declare the target, or a signed +`cybexos-desktop` RPM for that release and architecture which provides +`cybexos-supported-fedora = `. The RPM signature must verify against the +installed trusted keyring. Selecting local source is an explicit administrator +trust decision, as with bootstrap; the workflow verifies compatibility and +freezes the selected bytes, but does not claim a local checkout hash proves its +author. A Git checkout must be clean and only tracked files enter the frozen +payload. No branch is pulled, reset or switched. + +Make and test an external backup first. Supply a JSON receipt beside its tar +archives, with paths relative to the receipt: + +```json +{ + "v": 1, + "createdAt": "2030-01-01T12:00:00Z", + "archives": [{ + "path": "workstation.tar.zst", + "sha256": "REPLACE_WITH_THE_ARCHIVE_SHA256", + "covers": ["/home/john", "/etc", "/var/lib/xps-hardware", "/etc/pki/akmods"] + }] +} +``` + +Use the actual backup timestamp, account home and checksum. The validator +requires a backup from the last seven days on another filesystem UUID, hashes +each archive, reads it completely through tar and verifies its declared +coverage. The account home and `/etc` are mandatory, plus the hardware/signing +paths when present. Archive members should be root-relative (`home/john/…`, +`etc/…`). This verifies integrity and coverage; the independent restore test +remains part of preparing the backup. + +```sh +cybex upgrade-system check --target --source /path/to/reviewed-release --backup /mnt/backup/receipt.json +cybex upgrade-system prepare --target --source /path/to/reviewed-release --backup /mnt/backup/receipt.json +# On an RPM installation, use --rpm /path/to/signed-target.rpm instead. +cybex upgrade-system status +# Once status is ready, close applications and explicitly start the offline upgrade: +cybex upgrade-system reboot +``` + +`prepare` returns after starting a durable root service. It does not reboot or +install packages into the running OS. Status becomes `ready` only after the +DNF download succeeds and the rollback checkpoint is armed. Closing the terminal +does not cancel the service. Its journal is under the `downloadUnit` named by +status. The helper never adds `--allowerasing` or disables signatures. + +Preflight requires the normal managed Btrfs root, free space on root/var/boot, +a clean RPM database, completed current-release updates, the default current +kernel, no pending hardware reboot, usable signed repositories, and healthy +installed camera ABI/userspace checks. Enabled repository URLs must follow +`$releasever` or be release independent; a URL pinned to the old Fedora release +must be reviewed first. Do not point the running system at target-only package +repositories. For an RPM target, the old CybexOS channel is omitted from the +offline download and the explicitly signed target RPM is applied after boot. + +`cybex upgrade-system cancel` is available after a completed or failed download, +before reboot is scheduled. It checks the saved metadata fingerprint before +cleaning DNF's offline state. It refuses to interrupt active DNF work or delete +an offline transaction that another operation replaced. An interrupted worker +retains diagnostic state rather than guessing which cached transaction to erase. +A normal reboot before scheduling the upgrade does not strand the download: +cancel or schedule it afterwards if the installed Fedora release and the saved +offline metadata are unchanged and no offline reboot is already scheduled. + +The first target boot converges the frozen source with the saved installation +choices, or installs/reconciles the staged RPM. RPM convergence requires the +current package's durable reconciliation status and every account to be ready; +a successful command exit alone is insufficient. Recovery waits for mounts, +the system bus and network availability, with login ordered after recovery. +Failed convergence, root health, +kernel or signing checks select the previous root and vendor desktop checkpoint +and reboot before allowing login. Successful root checks produce +`awaiting-desktop`, not success: the validation timer waits for an actual +Hyprland session, then verifies the managed shell through the transaction health +checks. A recovery bar does not count as a healthy desktop. A failed desktop +check selects rollback and records `restartRequired`; the active session is not +abruptly rebooted. Use `cybex upgrade-system reboot` to enter that restored root. + +Status is private under `/var/lib/cybexos/major-upgrade/`. Frozen release payloads +are under `/var/lib/cybexos/major-upgrade-payloads//`; source is retained for +reproducibility, while a committed RPM payload or explicitly cancelled payload +is removed. The transaction journal and checkpoint are independently stored in +the Btrfs recovery store. Personal files and settings are never reverted by the +vendor checkpoint. The backup covers data outside that checkpoint. + +`tests/major-upgrade.py` exercises artifact compatibility, signature gates, +staging, the download/reboot boundary, cancellation ownership, backup checks, +boot failure/rollback, deferred desktop validation and process-group cleanup +using fixtures. It is not an end-to-end Fedora major-upgrade qualification. + +## Release engineer preparation + The playbook supports exactly the release named by `fedora_release`; this is a safety boundary, not a default. Prepare and test repository support for the next Fedora release before upgrading the workstation. Do not change the value @@ -21,8 +123,9 @@ branch for all compatibility changes. 4. Make and test a backup that covers the user's home, repository checkout, `/etc`, `/var/lib/xps-hardware`, and `/etc/pki/akmods`. Also record `rpm -qa`, `flatpak list --system`, enabled repositories, and the current - kernel. The rollback for a failed major upgrade is restore/reinstall, not an - attempted mass package downgrade. + kernel. The guided workflow restores its pre-upgrade root/vendor checkpoint on + failure; the external backup and recovery media cover unsupported layouts + and personal data. Never attempt a mass package downgrade. 5. Ensure the prepared target-release branch and recovery media are available without relying on this machine's graphical session. diff --git a/docs/installation-parity.md b/docs/installation-parity.md index 26824646..27988f1b 100644 --- a/docs/installation-parity.md +++ b/docs/installation-parity.md @@ -15,6 +15,7 @@ Fresh installations use these ISO defaults: | Docker administrator access | Sudo required | | Desktop automatic login | Enabled only after complete root encryption is verified | | Additional local-network firewall ports | Disabled; LocalSend retains its shared ports | +| Files SMB workgroup | `WORKGROUP`; explicit per-user workgroups take precedence | | Machine identity | Preserve the identity already configured by Fedora/Anaconda | `inventory/group_vars/all.yml` is the common default input. @@ -34,8 +35,9 @@ identity change in the checkout questionnaire still applies that choice. Neither parity nor a release update authorizes repartitioning an existing Fedora installation or resetting user settings to match a clean account. Fastfetch, Voxtype, Oh My Posh, MIME associations, and npm configuration are -seeded only when absent, as on the ISO. Managed Fish and Kitty fragments remain -updateable independently of those personal files. +seeded only when absent, as on the ISO. Managed Fish, Kitty, Git and SSH fragments remain updateable while their bytes +match the shared ownership ledger. Conflicting edits and deletions are preserved +on both paths. Git/Kitty includes precede user values; SSH fallbacks follow them. The checkout path installs onto existing Fedora and retains source-release updates and uninstall; the ISO uses Anaconda for disk/account creation and RPM @@ -65,3 +67,68 @@ revision and artifact digests in the qualification evidence. Fixture checks compare the installation contract; they are not evidence that fresh physical or VM installations have been performed. Do not use an older ISO qualification as evidence for a newer checkout. + +## Real installed-outcome gate + +`image/release-gate` requires two full checkout installations, `plain-us` and +`plain-nl`, in addition to the existing four graphical ISO installations and +prior-release RPM upgrade/recovery check. They use a checksum-pinned Fedora 44 +Cloud image, the same QEMU hardware/UEFI configuration as the ISO guests, all +normal feature defaults, the public `./install --non-interactive` entry point, +and real SDDM password login after reboot. `tests/fedora-vm-convergence` remains +a separate convergence/uninstall test; its feature opt-outs cannot satisfy +this gate. + +The builder includes the complete reviewed source set in `source.tar.gz` +alongside its ISO/RPM artifacts. A canonical digest covers file names, content +and executable bits, including intentional non-ignored working-tree changes. +It is embedded in the ISO's existing `build.json`. The builder verifies that +the archived content matches, so an edit during source capture aborts the +build. Checkout qualification extracts this exact archive, validates its +checksum and content, and requires the ISO qualification to identify that +exact source and ISO digest. Extraction rejects links, traversal and duplicate +paths. The runner's newer checkout cannot substitute for the tested source. + +Each guest produces `outcomes-fresh.json`, then saves explicit shell/input +preferences, a valid personal Hyprland override and unknown future settings +fields. It reapplies its own installation path, reboots, verifies those values +survived and produces `outcomes-saved.json`. Captures require a running desktop +and exactly one Quickshell process owned by `quickshell.service`. The managed +Settings lifecycle test exercises Network, Sound, Online Accounts, Keyboard, +Touchpad and Region, including watcher cleanup and the current QML journal. + +`image/installed_outcomes.py` compares effective shell/input settings, saved +installer choices, login policy, actual sudo authorization, Polkit policy, +account groups/shell, required RPM versions, Flatpaks, application commands and +associations, service enablement/activation, firewall policy, SELinux, recovery +support, filesystem and personal-file identities. It retains the complete RPM +inventories. Every additional package difference needs a reasoned entry in +`image/parity-exceptions.json`; required package/version differences always +fail. Initial exceptions cover only the ISO delivery RPM and Cloud provisioning +tools. Review new actual baseline differences before extending that file. + +`parity-fresh.json` and `parity-saved.json` name mismatches. The release gate +embeds passed same-source checkout evidence in both plain ISO reports. +`image/prepare-github-release` also rejects missing, stale or fixture-only +parity evidence when invoked independently. + +To rerun checkout comparison against a completed candidate (its corresponding +ISO qualification must have used `--capture-outcomes`): + +```sh +image/qualify-checkout --execute-vm --scenario plain-us \ + --artifacts /path/to/build/artifacts \ + --iso-results /path/to/qualification/plain-us \ + --output /path/to/new-task-specific-checkout-output +``` + +The runner removes task-owned VM disks, SSH keys, cloud seeds, transient logs +and screenshots on success/failure, retaining compact reports. +`--keep-artifacts` retains unresolved diagnostics, but never the cloud seed or +SSH private key. Testing OS ISOs still use `/data/pxe/iso` and the existing +checksum/iVentoy workflow. Existing Fedora hosts are never repartitioned. + +`image/test_installed_outcomes.py` tests content identities, archive validation, +comparison guards and guest workflow construction without booting a VM. These +source tests do not qualify an installation or imply the expanded matrix ran. +New release evidence must come from executing the gate. diff --git a/docs/integration-boundaries.md b/docs/integration-boundaries.md new file mode 100644 index 00000000..9de25202 --- /dev/null +++ b/docs/integration-boundaries.md @@ -0,0 +1,40 @@ +# Desktop integration boundaries + +The public QML singletons and bridge protocol remain compatible. Internally, +transport lifetime, domain state and view code have separate owners: + +- **Hermes:** `cybex_hermes/protocol.py` owns errors, wire frames and limits; + `auth.py` owns origin-scoped HTTP credentials and authenticated transport; + `registry.py` owns atomic conversation metadata; `gateway.py` owns bounded + local-client delivery and the reconnecting local upstream. `hermes_bridge.py` + coordinates the conversation domain and keeps its public imports for tools + and existing tests. Modules never import the bridge entrypoint. Both install + paths must ship the package beside the executable. +- **GitHub:** `GitHubQueue.js` implements deterministic deduplication and + interactive FIFO priority. `CommandRequest.qml` owns subprocess output, + launch failure and bounded termination. `GitHub.qml` owns caches, conditional + requests and Inbox reconciliation; popovers retain presentation only. +- **T3:** `T3Rpc.qml` owns wire correlation, request deadlines and interruption. + `T3Actions.qml` owns domain commands, capability checks, batches and action + feedback. The RPC facade forwards its existing command methods and + properties, preserving callers. Request acceptance still does not resolve + provider approvals; a disconnected or partially accepted batch never replays. +- **Updates:** `UpdateLogReader.qml` owns bounded byte-range transport and + run/offset validation. `Updates.qml` owns transaction state and parses only + accepted log data. The privileged updater still owns the transaction; a shell + reload does not stop it. Log responses for another run or an earlier offset + trigger a fresh read instead of modifying the current transaction. + +`CommandRequest` has `command`, `running`, `stdinEnabled`, `inputText`, +`timeoutMs`, `killGraceMs` and `timeoutMessage` inputs. Set the command and then +`running = true`. `completed(code, body, error)` fires once per request; +`available()` announces when the process slot is free. Launch failure has code +`-1`; timeout has code `124` and an empty body, including if a process printed +partial output before hanging. A second expiry sends SIGKILL. Do not launch the +next command while `running` is true. No command is retried by the transport. + +The real-engine lifecycle fixture covers output, missing executables, +SIGTERM-resistant timeouts, reuse after timeout and stdin. It runs in CI with an +isolated HOME/session when no other Quickshell is active. Node behavior tests +exercise GitHub queue priority and T3 partial-batch, approval and expiry rules; +the existing Hermes HTTP/WebSocket fixtures exercise the split Python modules. diff --git a/docs/native-system-settings.md b/docs/native-system-settings.md index 0dca5c77..682ec170 100644 --- a/docs/native-system-settings.md +++ b/docs/native-system-settings.md @@ -1,6 +1,7 @@ # Native system settings -CybexOS Settings now includes Network, Sound, Displays and Online Accounts. The +CybexOS Settings includes Network, Sound, Displays, Keyboard, Touchpad, Region +and Online Accounts. The Wi-Fi popover, network details, sound drawer and calendar account action open these pages. Settings search includes them too. The compact controls remain available. @@ -13,6 +14,9 @@ available. | Sound | Output and input device lists (network outputs folded), volume and mute, microphone level meter; ports of the default devices, output balance, hardware profiles, and per-application level, mute and playback/recording routing | `pavucontrol` for advanced controls | | Online Accounts | Provider, identity and attention status; calendar enable/disable; confirmed local removal; administrator locks | `gnome-online-accounts-gtk` for provider setup, browser authentication and reconnection | | Displays | Arrangement preview that also selects the display to edit, with drag and keyboard placement; per display: on/off, resolution, refresh rate, scale, rotation, flip, mirroring and adaptive sync; a 15-second trial before anything is saved | `~/.config/cybexos/hypr/user.lua` for monitor rules the page does not cover (bit depth, HDR, reserved areas) | +| Keyboard | Search installed XKB layouts and variants; add, remove and reorder up to four layouts; configure switching shortcuts and switch immediately | Personal `user.lua` rules retain final precedence | +| Touchpad | Tap to click, natural scroll direction and pointer sensitivity, drafted behind Apply; existing scroll speed control applies immediately | Pointer sensitivity is Hyprland's shared mouse/touchpad setting; per-device rules remain available in `user.lua` | +| Region & formats | Search installed timezones and locales, then explicitly apply system timezone or language; existing clock and temperature format controls | Normal system Polkit authorization; additional locales require Fedora language packs | GOA continues to own account authentication and credentials. The shell reads metadata and never requests access tokens or passwords. Calendar data still @@ -156,6 +160,51 @@ bit-depth controls, and custom modelines. Use `user.lua` for those. ## Validation and maintenance +### Keyboard, touchpad and system region + +Input preferences are user-owned data in +`$XDG_CONFIG_HOME/cybexos/input.json` (normally `~/.config/cybexos/input.json`). +Only edited fields are saved. Unknown top-level, section and unchanged-layout +metadata survive; version hashes reject concurrent writes. A malformed file or +symlink is left untouched and explained in Settings. The helper takes an +exclusive lock, atomically writes the new file, and reloads Hyprland. A rejected +reload restores the original bytes and reloads again; failure of that recovery +is explicitly reported. It never rewrites `user.lua`. + +The shared `input_preferences.lua` loader uses the existing bounded JSON +parser, validates all fields before applying any, and contains corrupt-file +errors so the compositor can start. It loads after the release's input defaults +and before `user.lua`. Therefore a personal override can intentionally supersede +a saved setting. Settings reads effective compositor values when refreshed. +Layout switching retains the release's compose/third-level options; selecting +Caps Lock as a switch replaces its Compose role. Switching to the next layout +changes the current session only, while the first saved layout is the sign-in +default. Shell reset/undo does not reset these input preferences. + +Region changes go through `org.freedesktop.timedate1.SetTimezone` and +`org.freedesktop.locale1.SetLocale` on the system bus, with normal interactive +Polkit authorization. No privileged helper, password capture or custom Polkit +exception is installed. Choices come from the installed timezone and locale +catalogs. Requests reject stale values, preserve existing `LC_*` overrides, +verify the resulting properties, and report authorization denial, cancellation +or service failure. Changing the system language sets `LANG`; applications use +it after signing out and back in. Separate timezone/language Apply actions +avoid a misleading partially successful combined change. Shell clock and +temperature formats remain independent personal settings. + +`tests/native-input-region.py` exercises the production helpers with isolated +preferences and mocked compositor/system buses, including failed reload +recovery, unknown-field preservation, stale writes, invalid inputs, symlinks, +real stdin/subprocess failure handling, authorization requests and preserved +locale categories. `tests/hyprland-input` executes the shipped Lua loader under +LuaJIT. `tests/qml/tst_input_draft.qml` exercises the pages' draft, reorder and +filter functions with Qt's JavaScript engine. These tests do not claim a live +authorization prompt, physical keyboard/touchpad or fresh-install validation. + +Both installation paths ship the same loader, pages and helpers, and use the +same XKB/timezone package resources. No installer applies these personal or +system region choices implicitly during reconfiguration. + Run the normal repository gate before deployment: ```sh diff --git a/docs/operations.md b/docs/operations.md index 6c3738c1..c821ea87 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -98,7 +98,8 @@ The stable role boundaries are `base`, `desktop`, `apps`, `xps-2026` (also `hardware`), `dotfiles`, `private-hooks`, `boot`, and `finalize`. Narrow tags currently exist for `browser`, `onepassword`, `fonts`, `font-defaults`, `packages`, `quickshell`, `quickshell-lint`, `shell-defaults`, `user-tools`, `camera`, `fingerprint`, -`speaker`, and `touchpad`. The +`speaker`, `touchpad`, and `display`. The `display` tag applies the measured +XPS panel self-refresh quirk; see [hardware notes](xps-2026-hardware.md#internal-panel-black-flashes). The narrow tags are development tools, not independent installation profiles; their prerequisites can live in an earlier role. @@ -265,16 +266,23 @@ The verified archive is extracted into a new versioned directory. A dedicated durable system worker owns configuration migration, candidate application, agent-skill reconciliation, rollback, and the atomic `current` symlink change. Detaching the terminal cannot split those steps, and an unrelated active -update is never accepted as the candidate transaction. Apply failure restores -the pre-migration configuration; activation failure also restores the prior -`current` target and every agent-skill slot. Files that Ansible had already -deployed from the candidate are not rolled back, so after a failed or -abandoned apply the run's `status.json` records `mixedState: true`: the -machine runs the previous release with some newer managed files. Retry -`cybex update` once the cause is fixed, or converge the active release again -with `~/.local/share/cybexos/current/install`. The active release plus two -recent release directories are retained as recovery material; filesystem -rollback remains the supported way to reverse system package changes. +update is never accepted as the candidate transaction. On the managed Btrfs +layout, the worker checkpoints root, `/boot`, and the exact vendor-owned home +paths before applying changes. It downloads RPMs first, applies the update, +waits for RPM desktop reconciliation where applicable, and validates system +and desktop health before committing. Failure or cancellation after application +selects the previous root and restores the vendor desktop; restart when status +reports `rollbackState: restart-required`. Personal preferences, themes, plugins +and application data under home are excluded from this restore. The active +release plus two recent release directories are retained, with pruning deferred +until the health check succeeds. + +On other filesystems `transactionProtection` is `unavailable`: release activation +still restores the prior `current` and saved configuration on failure, but +already-deployed files and package changes require manual recovery. Such a failed +apply reports `mixedState: true`; converge the active release again with +`~/.local/share/cybexos/current/install` after fixing the cause. See the recovery +limits below before relying on rollback. Useful release commands are: @@ -328,7 +336,7 @@ contract is not cancellable. `dismiss` only changes the completed status shown by the UI and does not delete its logs. `--firmware` (also accepted by `cybex update`) installs available fwupd device -firmware in the same worker after the package phase, through +firmware in the same worker after the reversible update has committed, through `cybexos-firmware-update`. A firmware failure never fails the run; the final message notes the helper's status, and a capsule staged for the next boot recommends a restart for the rest of that boot. With `--no-packages`, @@ -339,8 +347,8 @@ directory under `logs//` containing: - `status.json`: atomic machine-readable phase, result, component exit codes, timestamps, transient unit name, the pre-update `snapshotId` when one was - created, and `mixedState` when a release apply stopped after Ansible began - changing files; + created, `transactionProtection` (`btrfs` or `unavailable`), `rollbackState`, + and `mixedState` when the running root still needs recovery or a restart; - `run.log`: the complete combined stream with `dnf`, `flatpak`, `firmware`, `tests`, and `ansible` prefixes; - component logs such as `dnf.log`, `flatpak.log`, `firmware.log`, @@ -369,6 +377,40 @@ system Flatpaks) is inside `root` and rolls back with it. A snapshot failure stops the update before DNF changes anything. On a non-Btrfs root the step records that no filesystem recovery point was required. +The transaction journal and vendor checkpoint live in the Btrfs top-level +recovery store, outside the root that is restored. An active checkpoint pins +its recovery point against ordinary retention. `cybexos-update-recover.service` +resolves interrupted updates before login, retrying an interrupted restoration +without exchanging the root twice. A prepared transaction with no applied +changes is abandoned safely. A failed restoration enters emergency mode instead +of starting a desktop with incomplete recovery. + +The first update from an older installation creates an untouched recovery point, +installs a root-owned recovery bundle and login dependency, then creates a second +point containing that hook. Package changes begin only after the second point +and vendor checkpoint succeed. This keeps recovery available if power fails +between restoring root and restoring vendor files in home. Normal convergence +replaces the bootstrap hook; a package-only update can retain it until that +convergence. Starting the boot service during the current update never rolls +back that same-boot worker. + +Commit checks the RPM database and newly failed system services. When the +desktop was active, it also requires the managed Quickshell service to own the +sole shell process, complete read-only IPC initialization, remain stable, and +report no QML errors for that invocation. Safe mode is not successful update +validation. Headless updates validate system health; they cannot validate a +desktop session that is not running. + +These are recoverable filesystem transactions, not whole-machine snapshots. +Root selection takes effect after restart, and a new kernel that cannot boot +after an otherwise successful commit still needs the recovery boot menu. +User Flatpaks, independently updated application/tool stores in home, external +filesystems, remote effects and firmware are outside the checkpoint. Firmware +runs only after commit. Keep external backups; root rollback also reverts data +in `/var` on the standard layout. Guided Fedora release upgrades use the same +journal with separate offline-boot and desktop validation stages; see +[the major-upgrade workflow](fedora-major-upgrade.md). + ```bash sudo cybexos-system-snapshot list # ID and description sudo cybexos-system-snapshot list --json # also kernel and boot-menu status diff --git a/docs/releasing.md b/docs/releasing.md index df4ee9ad..f2c452db 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -70,12 +70,15 @@ repository. Do not edit an existing release. Immutability makes correction explicit: fix forward, increment the version, and publish a new tag. If rollout must stop, remove the bad release from channel discovery and publish a corrected release. -Users whose system apply failed keep their previous active release (`current`) -and their prior saved configuration, but files that Ansible had already -deployed from the failed candidate stay in place; the run records -`mixedState: true`. The corrected release converges them, and -`~/.local/share/cybexos/current/install` restores the previous release's files -in the meantime. +On the managed Btrfs layout, failed application or health validation selects +the previous root and restores the vendor desktop checkpoint while preserving +personal settings; users must restart to enter the restored root. Interrupted +application is recovered before login. On other filesystems the updater reports +`transactionProtection: unavailable`: failed activation restores the previous +`current` and saved configuration, but partially deployed files can remain +(`mixedState: true`). Converge the active release with +`~/.local/share/cybexos/current/install` or apply a corrected release. +See [update recovery and its limits](operations.md#update-recovery-points). ## What the source workflow publishes diff --git a/docs/shell-recovery.md b/docs/shell-recovery.md new file mode 100644 index 00000000..ae1d81fd --- /dev/null +++ b/docs/shell-recovery.md @@ -0,0 +1,40 @@ +# Shell crash recovery + +The managed Quickshell service records failed invocations without supervising a +second process. The launcher replaces itself with `qs`, so the service MainPID +remains the only shell process. Three failed invocations within two minutes +select the standalone recovery configuration on the next start. Successful +service stops, deliberate restarts, a long healthy run, and a new boot reset the +failure counter. A late stop notification cannot override a recovery choice. + +Recovery uses `quickshell/safe-mode/shell.qml`. It provides a bar on every output, +a clock, a terminal button, and **Retry desktop**. Super+Space opens a terminal. +It imports no normal settings, theme, plugins or connected integrations, so a +broken widget or personal setting cannot prevent the recovery UI from loading. +The minimal desktop does not provide the normal notification or authentication +interfaces. User settings and plugin enablement are never rewritten. + +```sh +cybex shell status # JSON status, last exit and selected runtime +cybex shell safe # enter recovery and restart the managed service +cybex plugin list # inspect installed plugins while in recovery +cybex plugin disable PLUGIN_ID # disable an identified broken plugin normally +cybex shell recover # clear the crash counter and retry the full shell +``` + +**Retry desktop** performs the same action as `cybex shell recover`. If the +problem persists, the next three failures return to recovery. If it began in a +development checkout, `cybex dev disable` also remains available. Recovery does +not guess which plugin caused an exit: the status record and current service +journal support diagnosis without discarding working settings. + +The private, atomic state record is +`$XDG_STATE_HOME/cybexos/shell-recovery.json` (normally +`~/.local/state/cybexos/shell-recovery.json`). It stores lifecycle metadata only. +Malformed state chooses recovery. Explicit recovery remains active across +reboots until the user retries; stale failure counts do not cross boots. Older +runtimes without this mechanism retain their normal launch behavior on rollback. + +`tests/shell-recovery.py` exercises crash accounting, stale stop callbacks, +normal restarts, boot boundaries, recovery commands, malformed state and exact +preservation of shell/plugin configuration. It does not launch a live shell. diff --git a/docs/xps-2026-hardware.md b/docs/xps-2026-hardware.md index 19887124..92bf8619 100644 --- a/docs/xps-2026-hardware.md +++ b/docs/xps-2026-hardware.md @@ -37,6 +37,39 @@ brightnessctl set 40% The first two commands should report `xe.enable_dpcd_backlight=1` and `1`; the last should visibly change panel luminance. +## Internal-panel black flashes + +On the XPS 14 DA14260 (SKU `0DB9`) with LGD product `0x07c6`, Fedora kernel +`7.2.7-200.fc44` exhibited repeated full-screen black flashes at 120 Hz. +The panel reported a PSR link CRC error, PSR2 selective fetch was enabled, +and the boot journal recorded `Selective fetch area calculation failed in +pipe A`. Disabling PSR live stopped the flashes without changing brightness, +resolution or refresh rate. This is separate from an absent Quickshell +wallpaper when the desktop session target has not started. + +The shared hardware role limits `xe.enable_psr=0` to this SKU and the full +EDID manufacturer/product bytes `30e4c607`. Both checkout provisioning and +ISO hardware setup use this task. `grubby --update-kernel=ALL` applies it to +installed kernels and the defaults for future kernels. Disabling PSR costs +some idle display power; it retains 120 Hz. The `display` tag applies only +this quirk, after the role's read-only hardware detection: + +```bash +ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags display --check --diff +ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags display +``` + +The boot argument takes effect on the next normal boot. On a running system, +`/sys/kernel/debug/dri/0/i915_edp_psr_status` reports the active PSR mode +(the debugfs name is retained by the Xe driver). During diagnosis, writing +`1` to `i915_edp_psr_debug` disables PSR for the current boot; `0` restores +the driver's default. Verify the DRM device before using either path. + +Keep `xps_2026_psr_disabled_panels: []` in the saved Ansible configuration to +opt out of further enforcement when evaluating a driver fix, then remove +`xe.enable_psr` with `grubby --update-kernel=ALL --remove-args=xe.enable_psr`. +No other panel or machine receives the workaround. + ## Internal speakers SKU `0DB9` (plus Quattro-listed XPS 16 SKU `0DBA`) gets Omarchy Quattro's current diff --git a/image/Containerfile.tests b/image/Containerfile.tests index 8847325f..f6bfbfa2 100644 --- a/image/Containerfile.tests +++ b/image/Containerfile.tests @@ -1,8 +1,11 @@ FROM fedora:44 -RUN dnf install -y python3-pyside6 python3-jinja2 python3-pyyaml python3-gobject-base glib2 \ +RUN dnf install -y python3-pyside6 python3-jinja2 python3-pyyaml python3-gobject-base glib2 gvfs \ ShellCheck pykickstart qt6-qtdeclarative-devel desktop-file-utils \ - nodejs24 gnupg2 git ripgrep ansible-core rpm-build rpm-sign createrepo_c \ + nodejs24 gnupg2 git ripgrep luajit ansible-core rpm-build rpm-sign createrepo_c \ && dnf clean all +# CI mounts the host-owned checkout read-only while this test image runs as +# root. Trust only that explicit mount for source archive enumeration. +RUN git config --system --add safe.directory /source ENV QT_QPA_PLATFORM=offscreen WORKDIR /source ENV PYTHONDONTWRITEBYTECODE=1 diff --git a/image/build b/image/build index b933504e..de017a85 100755 --- a/image/build +++ b/image/build @@ -10,13 +10,13 @@ import signal import socket import subprocess import sys -import tarfile import tempfile import time from build_support import (atomic_json, build_id, builder_cloud_config, create_seed, deliver_artifacts, digest, phase, preflight, source_provenance, validate_qemu_path, wait_for_builder_initialization) +from source_snapshot import archive_identity, tree_identity, write_archive ROOT = Path(__file__).resolve().parents[1] IMAGE = "Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2" @@ -29,26 +29,7 @@ def run(args, **kwargs): def source_archive(destination, additions=None): - roots = ["image", "roles/desktop", "assets/scripts", "assets/EDM115-newline2.omp.json", - "assets/desktop-contract.json", "assets/wallpapers", "assets/PROVENANCE.json", - "roles/boot/files", "roles/boot/defaults/main.yml", "inventory/group_vars/all.yml", "VERSION", "LICENSE", - "roles/dotfiles", "roles/apps", "roles/base", "roles/xps-2026", - "agent-skills/cybexos", "scripts/manage-agent-skills", - "assets/nautilus-localsend.py"] - excluded = {"image/update-channel.json", "image/update-key.asc", "image/build-provenance.json"} - with tarfile.open(destination, "w:gz") as archive: - for name in roots: - path = ROOT / name - if not path.exists(): - raise FileNotFoundError(f"Missing image source input: {name}") - for item in sorted(path.rglob("*")) if path.is_dir() else [path]: - relative = str(item.relative_to(ROOT)) - if item.is_file() and not item.is_symlink() and relative not in excluded and "__pycache__" not in item.parts and item.suffix != ".pyc": - archive.add(item, arcname=relative, recursive=False) - for name, path in (additions or {}).items(): - if name not in excluded: - raise ValueError("Unexpected generated source input") - archive.add(path, arcname=name, recursive=False) + write_archive(ROOT, destination, additions) def stop(process): @@ -116,9 +97,12 @@ def main(): source_dirty=bool(subprocess.check_output(["git", "-C", str(ROOT), "status", "--porcelain"], text=True).strip()), source_epoch=int(subprocess.check_output(["git", "-C", str(ROOT), "show", "-s", "--format=%ct", "HEAD"], text=True))) installed = {key: provenance[key] for key in ("utc", "build_id", "source_revision", "source_dirty", "source_epoch")} + installed.update(tree_identity(ROOT)) atomic_json(work / "build-provenance.json", installed) additions["image/build-provenance.json"] = work / "build-provenance.json" source_archive(work / "source.tar.gz", additions) + if archive_identity(work / "source.tar.gz") != {key: installed[key] for key in ('source_content_sha256', 'source_file_count')}: + raise RuntimeError('Source changed while preparing the build archive; retry from a stable checkout') provenance["source"] = source_provenance(ROOT, work / "source.tar.gz") # -no-user-config: host /etc/qemu may be unreadable and must not shape the builder. provenance["tools"] = {command[0]: subprocess.check_output([*command, "--version"], text=True).splitlines()[0] @@ -182,6 +166,7 @@ def main(): run(["scp", *ssh_options, "-P", str(port), "-r", "builder@127.0.0.1:/home/builder/artifacts/.", staging]) from build_support import checksum_entries checksum_entries(staging) + shutil.copyfile(work / "source.tar.gz", Path(staging) / "source.tar.gz") original = Path(staging) / "CybexOS-Live-44.iso" original.rename(Path(staging) / f"CybexOS-Live-44-{identifier}.iso") files = sorted(path for path in Path(staging).iterdir() if path.name != "SHA256SUMS") diff --git a/image/cybexos-desktop.spec b/image/cybexos-desktop.spec index 45aebeee..aa5c3258 100644 --- a/image/cybexos-desktop.spec +++ b/image/cybexos-desktop.spec @@ -11,6 +11,7 @@ License: MIT AND LicenseRef-CybexOS-Bundled-Components URL: https://github.com/DigitalPals/CybexOS Source0: desktop.tar BuildArch: x86_64 +Provides: cybexos-supported-fedora = %{fedora} AutoReqProv: no # Replaces the alpha package published under the project's former name. Obsoletes: fedora-config-desktop < %{epoch}:%{version}-%{release} @@ -35,6 +36,7 @@ Requires: ImageMagick tesseract tesseract-langpack-eng btop matugen Requires: btrfs-progs tar zstd fastfetch dracut grub2-tools coreutils >= 9.5 Requires: rsms-inter-fonts google-noto-sans-fonts google-noto-color-emoji-fonts Requires: jetbrains-mono-fonts +Requires: xkeyboard-config tzdata %description CybexOS (Cybex Opinionated System) is a Hyprland and Quickshell desktop for Fedora. @@ -70,6 +72,7 @@ cp -a usr opt etc %{buildroot}/ /usr/bin/cybexos-* /usr/bin/hyprland-quickshell /usr/libexec/cybexos-* +/usr/libexec/cybex_hermes/ /usr/lib/systemd/user/*.service /usr/lib/systemd/user/hypridle.service.d/ /usr/lib/systemd/user/voxtype.service.d/ @@ -80,6 +83,7 @@ cp -a usr opt etc %{buildroot}/ /usr/share/wayland-sessions/hyprland-quickshell.desktop /usr/share/fonts/cybexos/ /usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override +/usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override /usr/share/licenses/cybexos-fonts/ /usr/share/plymouth/themes/cybex/ /usr/lib/sysctl.d/60-cybexos-hardening.conf @@ -88,6 +92,10 @@ cp -a usr opt etc %{buildroot}/ /usr/lib/dracut/dracut.conf.d/90-cybexos-recovery.conf /usr/lib/dracut/modules.d/90cybexos-recovery/ /usr/lib/systemd/system/cybexos-recovery-refresh.service +/usr/lib/systemd/system/cybexos-update-recover.service +/usr/lib/systemd/system/systemd-user-sessions.service.d/60-cybexos-update-recover.conf +/usr/lib/systemd/system/cybexos-major-upgrade-validate.service +/usr/lib/systemd/system/cybexos-major-upgrade-validate.timer /usr/lib/systemd/system/cybexos-reconcile.service /usr/lib/systemd/system/cybexos-reconcile.timer /usr/lib/systemd/system/cybexos-hardware-setup.service @@ -107,6 +115,8 @@ if [ "$1" -eq 1 ]; then fi # Bootable recovery points are refreshed at every boot; enabling is idempotent. systemctl enable cybexos-recovery-refresh.service >/dev/null 2>&1 || : +systemctl enable cybexos-update-recover.service >/dev/null 2>&1 || : +systemctl enable cybexos-major-upgrade-validate.timer >/dev/null 2>&1 || : systemctl enable cybexos-hardware-setup.timer >/dev/null 2>&1 || : %changelog diff --git a/image/desktop_payload.py b/image/desktop_payload.py index 9ff908a5..f0f314b4 100644 --- a/image/desktop_payload.py +++ b/image/desktop_payload.py @@ -39,9 +39,8 @@ def prepare_defaults(root, payload, environment, inventory): return contract -# blockinfile's rendering of the workstation's managed Kitty include -# (roles/dotfiles/tasks/personal.yml), so provisioning a seeded account finds -# its block already present instead of appending a second one. +# The shared cybexos_user_include module's first-run Kitty block. User +# preferences follow it, so explicit values have precedence over defaults. KITTY_INCLUDE = "# BEGIN CYBEXOS MANAGED INCLUDE\ninclude cybexos.conf\n# END CYBEXOS MANAGED INCLUDE\n" diff --git a/image/github_release.py b/image/github_release.py index 2bd3d326..7e5644cf 100644 --- a/image/github_release.py +++ b/image/github_release.py @@ -189,6 +189,17 @@ def verify_qualifications(paths, iso_digest, packages): if not isinstance(prior, str) or not re.fullmatch(r'[0-9a-f]{64}', prior): raise ValueError('Qualification must identify the tested ISO SHA-256') if prior == iso_digest and 'graphical-installer' in checks and report.get('scenario') in scenarios: + if report['scenario'].startswith('plain-'): + checkout = report.get('checkout_parity', {}) + content = report.get('source_content_sha256', '') + if (not isinstance(content, str) or not re.fullmatch(r'[0-9a-f]{64}', content) + or not {'installed-parity-fresh', 'installed-parity-saved'} <= set(checks) + or checkout.get('status') != 'passed' or checkout.get('scenario') != report['scenario'] + or checkout.get('source_content_sha256') != content + or checkout.get('source_revision') != report.get('source_revision') + or not {'full-checkout-installation', 'installed-parity-fresh', 'installed-parity-saved', + 'full-graphical-session'} <= set(checkout.get('checks', []))): + raise ValueError('Release requires same-source real checkout/ISO parity for fresh and saved choices') fresh.add(report['scenario']) if (prior != iso_digest and report.get('candidate_rpm_sha256') in candidates and {'installed-rpm-upgrade', 'recovery-boot-restore'} <= set(checks)): diff --git a/image/installed_outcomes.py b/image/installed_outcomes.py new file mode 100644 index 00000000..54c2d5f2 --- /dev/null +++ b/image/installed_outcomes.py @@ -0,0 +1,231 @@ +"""Capture real installed outcomes and compare checkout installation with ISO. + +Capture is read-only and must run as root inside a disposable qualification +guest with a live graphical account. Never substitute fixture output for it. +""" +import argparse +import configparser +import grp +import hashlib +import json +import os +from pathlib import Path +import pwd +import re +import shlex +import subprocess +import xml.etree.ElementTree as ET + +SYSTEM_UNITS = ('NetworkManager.service', 'firewalld.service', 'avahi-daemon.service', 'cups.service', + 'fstrim.timer', 'fwupd-refresh.timer', 'tuned.service', 'tuned-ppd.service', 'bluetooth.service', + 'tailscaled.service', 'docker.socket', 'docker.service', 'sddm.service', + 'cybexos-recovery-refresh.service', 'cybexos-update-recover.service') +USER_UNITS = ('quickshell.service', 'hypridle.service', 'voxtype.service', 'hyprland-session.target', + 'cybexos-session-lock.service', 'cybexos-input-method.service') +COMMANDS = ('cybex', 'fastfetch', 'claude', 'opencode', 'codex', 'cargo', 'rustup', 'node', 'npm', + 'bun', 'lazygit', 'lazydocker', 'balena', 'mdview', 'awww', 'wayfreeze', 'voxtype', + 'localsend', 't3code-desktop', 'adb') +CHOICES = ('config_schema_version', 'machine_timezone', 'machine_locale', 'regional_locale', + 'machine_keyboard_layout', 'machine_keyboard_variant', 'manage_system_identity', + 'manage_personal_dotfiles', 'passwordless_wheel', 'passwordless_local_polkit', + 'docker_sudoless', 'desktop_autologin', 'start_optional_hardware_services', + 'allow_insecure_sccache_transport', 'features') +CAPTURE_FIELDS = {'format', 'installation', 'scenario', 'profile', 'source_revision', 'source_content_sha256', + 'hardware', 'required_packages', 'installed_packages', 'flatpaks', 'commands', 'choices', + 'system_units', 'user_units', 'settings', 'input', 'authentication', 'firewall', + 'default_apps', 'personal', 'recovery', 'selinux', 'graphical_session', 'sole_managed_shell'} + + +def command(args, *, environment=None, accepted=(0,)): + result = subprocess.run(args, env=environment, capture_output=True, text=True, timeout=40, check=False) + if result.returncode not in accepted: + raise RuntimeError('Installed outcome read failed: ' + ' '.join(args[:3]) + ' … ' + str(args[-1])[:160]) + return result.stdout.strip() + + +def read_json(path): + return json.loads(Path(path).read_text()) + + +def normalize(value, home): + if isinstance(value, str): + return value.replace(home, '$HOME').replace('/usr/share/cybexos/runtime', '$RUNTIME').replace('$HOME/.local/share/cybexos/runtime', '$RUNTIME') + if isinstance(value, dict): + return {key: normalize(item, home) for key, item in value.items()} + if isinstance(value, list): + return [normalize(item, home) for item in value] + return value + + +def capture(user, installation, scenario, profile, manifest, provenance): + import yaml + if os.geteuid() != 0 or user != 'qualification' or command(['systemd-detect-virt']) not in ('kvm', 'qemu'): + raise RuntimeError('Capture requires the disposable qualification guest and account') + account = pwd.getpwnam(user) + home = Path(account.pw_dir) + environment = {**os.environ, 'HOME': str(home), 'USER': user, 'LOGNAME': user, + 'XDG_RUNTIME_DIR': '/run/user/' + str(account.pw_uid), + 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/' + str(account.pw_uid) + '/bus', + 'PATH': ':'.join([str(home / relative) for relative in ('.local/bin', '.cargo/bin', '.npm-global/bin', 'Android/Sdk/platform-tools')] + + ['/usr/local/bin', '/usr/bin', '/bin'])} + def user_command(args, accepted=(0,)): + return command(['runuser', '-u', user, '--', *args], environment=environment, accepted=accepted) + for line in user_command(['systemctl', '--user', 'show-environment']).splitlines(): + if line.startswith(('HYPRLAND_INSTANCE_SIGNATURE=', 'WAYLAND_DISPLAY=')): + key, value = line.split('=', 1) + environment[key] = value + if not environment.get('HYPRLAND_INSTANCE_SIGNATURE'): + raise RuntimeError('Capture requires an actual running Hyprland desktop') + build = read_json(provenance) + for key, pattern in (('source_revision', r'[0-9a-f]{40,64}'), ('source_content_sha256', r'[0-9a-f]{64}')): + if not re.fullmatch(pattern, str(build.get(key, ''))): + raise RuntimeError('Installed build is missing exact source provenance') + applications = read_json(manifest) + required = {} + for selector in applications['packages']: + required[selector] = sorted(command(['rpm', '-q', '--qf', '%{NAME}.%{ARCH}=%{EVR}\n', selector]).splitlines()) + installed = sorted(command(['rpm', '-qa', '--qf', '%{NAME}.%{ARCH}=%{EVR}\n']).splitlines()) + flatpaks = sorted(command(['flatpak', 'list', '--system', '--app', '--columns=application,branch,commit']).splitlines()) + flatpak_ids = {line.split()[0] for line in flatpaks} + if not set(applications['flatpaks']) <= flatpak_ids: + raise RuntimeError('Installed Flatpak application contract is incomplete') + executable = {} + for name in COMMANDS: + # No shell expansion; the user environment supplies both installation paths. + executable[name] = user_command(['python3', '-c', 'import os,shutil,sys; p=shutil.which(sys.argv[1]); print(bool(p and os.access(p,os.X_OK)))', name]) == 'True' + if not all(executable.values()): + raise RuntimeError('Application commands missing: ' + ', '.join(name for name, present in executable.items() if not present)) + config = yaml.safe_load(Path('/etc/cybexos/config.yml').read_text()) + settings = json.loads(user_command(['cybexos-runtime', 'ipc', 'settings', 'values'])) + units = {} + for name in SYSTEM_UNITS: + units[name] = command(['systemctl', 'is-enabled', name], accepted=(0, 1, 3, 4)) + user_units = {} + for name in USER_UNITS: + # Static units are pulled into the session target; compare activation too. + user_units[name] = {'enabled': user_command(['systemctl', '--user', 'is-enabled', name], accepted=(0, 1, 3, 4)), + 'active': user_command(['systemctl', '--user', 'is-active', name], accepted=(0, 1, 3, 4))} + main_pid = user_command(['systemctl', '--user', 'show', 'quickshell.service', '-p', 'MainPID', '--value']) + processes = user_command(['pgrep', '-x', 'qs'], accepted=(0, 1)).splitlines() + if processes != [main_pid] or main_pid in ('', '0'): + raise RuntimeError('Managed Quickshell must be the sole running shell') + sudo = subprocess.run(['runuser', '-u', user, '--', 'sudo', '-k', '-n', 'true'], capture_output=True, timeout=10) + if sudo.returncode not in (0, 1) or (sudo.returncode == 0) != config.get('passwordless_wheel'): + raise RuntimeError('Effective sudo authorization differs from the saved installation choice') + login = read_json('/etc/cybexos/login.json') + sddm = configparser.ConfigParser() + sddm.read('/etc/sddm.conf') + zone = ET.parse('/etc/firewalld/zones/cybexos.xml').getroot() + policy = sorted(ET.tostring(node, encoding='unicode').strip() for node in zone) + groups = sorted(group.gr_name for group in grp.getgrall() if user in group.gr_mem) + keyboard = {} + for name in ('kb_layout', 'kb_variant', 'kb_options', 'repeat_rate', 'sensitivity', 'touchpad:tap_to_click', 'touchpad:natural_scroll', 'touchpad:scroll_factor'): + result = json.loads(user_command(['hyprctl', '-j', 'getoption', 'input:' + name])) + keyboard[name] = {key: result[key] for key in ('int', 'float', 'str') if key in result} + personal = {} + for relative in ('.config/cybexos/input.json', '.config/cybexos/hypr/user.lua', 'qualification-personal-marker'): + path = home / relative + personal[relative] = hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else None + default_apps = {mime: user_command(['xdg-mime', 'query', 'default', mime]) for mime in + ('text/html', 'inode/directory', 'application/pdf', 'x-scheme-handler/http', 'x-scheme-handler/https')} + if not all(default_apps.values()): + raise RuntimeError('A standard application association is missing') + recovery = next((path for path in ('/usr/libexec/cybexos-system-snapshot', '/usr/local/libexec/cybexos-system-snapshot') if Path(path).is_file()), None) + if not recovery: + raise RuntimeError('Recovery helper is missing') + recovery_state = json.loads(command([recovery, 'list', '--json'])) + hardware = {name: Path('/sys/class/dmi/id/' + name).read_text().strip() for name in ('sys_vendor', 'product_name')} + result = {'format': 1, 'installation': installation, 'scenario': scenario, 'profile': profile, + 'source_revision': build['source_revision'], 'source_content_sha256': build['source_content_sha256'], + 'hardware': hardware, 'required_packages': required, 'installed_packages': installed, + 'flatpaks': flatpaks, 'commands': executable, 'choices': {key: config.get(key) for key in CHOICES}, + 'system_units': units, 'user_units': user_units, 'settings': settings, 'input': keyboard, + 'authentication': {'login': login, 'shell': account.pw_shell, 'groups': groups, + 'passwordless_sudo': sudo.returncode == 0, + 'passwordless_polkit': Path('/etc/polkit-1/rules.d/49-wheel-local.rules').exists(), + 'autologin_user': sddm.get('Autologin', 'User', fallback='')}, + 'firewall': {'default_zone': command(['firewall-cmd', '--get-default-zone']), 'policy': policy, + 'permanent_services': sorted(command(['firewall-cmd', '--permanent', '--zone=cybexos', '--list-services']).split()), + 'permanent_ports': sorted(command(['firewall-cmd', '--permanent', '--zone=cybexos', '--list-ports']).split())}, + 'default_apps': default_apps, 'personal': personal, + 'recovery': {'supported': recovery_state.get('supported'), + 'root_filesystem': command(['findmnt', '-n', '-o', 'FSTYPE', '/'])}, + 'selinux': command(['getenforce']), 'graphical_session': True, 'sole_managed_shell': True} + return normalize(result, str(home)) + + +def compare(iso, checkout, exceptions): + """No implicit allowlist: every extra package difference needs a reason.""" + if iso.get('installation') != 'iso' or checkout.get('installation') != 'checkout': + raise ValueError('Comparison requires ISO and checkout captures') + for key in ('source_revision', 'source_content_sha256', 'scenario', 'profile', 'hardware'): + if not iso.get(key) or iso[key] != checkout.get(key): + raise ValueError('Installed outcomes cannot be compared: mismatched ' + key) + for result in (iso, checkout): + if CAPTURE_FIELDS - set(result): + raise ValueError('Installed outcomes are incomplete: ' + ', '.join(sorted(CAPTURE_FIELDS - set(result)))) + if result.get('format') != 1 or result.get('graphical_session') is not True or result.get('sole_managed_shell') is not True: + raise ValueError('Installed outcomes lack a verified graphical session') + for key in ('required_packages', 'settings', 'commands', 'choices', 'system_units', 'user_units', 'input', 'authentication', 'firewall', 'default_apps', 'recovery'): + if not isinstance(result[key], dict) or not result[key]: + raise ValueError('Installed outcomes have no effective ' + key) + ignored = {'installation', 'installed_packages'} + differences = {key: {'iso': iso.get(key), 'checkout': checkout.get(key)} for key in sorted(set(iso) | set(checkout)) + if key not in ignored and iso.get(key) != checkout.get(key)} + package_delta = {} + for side, other in (('iso', 'checkout'), ('checkout', 'iso')): + own = iso if side == 'iso' else checkout + peer = checkout if other == 'checkout' else iso + delta = sorted(set(own['installed_packages']) - set(peer['installed_packages'])) + approved = exceptions.get(side, {}) + if not isinstance(approved, dict) or any(not isinstance(reason, str) or len(reason.strip()) < 12 for reason in approved.values()): + raise ValueError('Every package exception must include a review reason') + unreviewed = [package for package in delta if package.split('=', 1)[0] not in approved] + package_delta[side] = {'all': delta, 'unreviewed': unreviewed} + reviewed = not any(item['unreviewed'] for item in package_delta.values()) + return {'format': 1, 'status': 'failed' if differences or not reviewed else 'passed', + 'source_revision': iso['source_revision'], 'source_content_sha256': iso['source_content_sha256'], + 'scenario': iso['scenario'], 'profile': iso['profile'], 'differences': differences, 'package_delta': package_delta, + 'checks': ['real-installed-outcomes', 'same-source-content', 'graphical-session'] + (['package-delta-reviewed'] if reviewed else [])} + + +def capture_guest(vm, root_script, password, *, installation, scenario, profile, manifest, provenance): + args = ['--capture', '--user', 'qualification', '--installation', installation, '--scenario', scenario, + '--profile', profile, '--manifest', manifest, '--provenance', provenance] + script = 'python3 - ' + shlex.join(args) + " <<'CYBEXOS_OUTCOMES_PY'\n" + Path(__file__).read_text() + '\nCYBEXOS_OUTCOMES_PY\n' + try: + return json.loads(root_script(vm, script, password, timeout=300).stdout) + except subprocess.CalledProcessError as error: + detail = ((error.stdout or '') + (error.stderr or '')).replace(password, '[redacted]')[-4000:] + raise RuntimeError('Installed outcome capture failed: ' + detail) from error + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--capture', action='store_true') + parser.add_argument('--user', default='qualification') + parser.add_argument('--installation', choices=('iso', 'checkout')) + parser.add_argument('--scenario') + parser.add_argument('--profile', default='fresh') + parser.add_argument('--manifest') + parser.add_argument('--provenance') + parser.add_argument('--iso', type=Path) + parser.add_argument('--checkout', type=Path) + parser.add_argument('--exceptions', type=Path, default=Path(__file__).with_name('parity-exceptions.json')) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.capture: + if not all((args.installation, args.scenario, args.manifest, args.provenance)): + parser.error('Capture needs installation, scenario, manifest and provenance') + result = capture(args.user, args.installation, args.scenario, args.profile, args.manifest, args.provenance) + else: + if not all((args.iso, args.checkout, args.output)): + parser.error('Comparison needs --iso --checkout --output') + result = compare(read_json(args.iso), read_json(args.checkout), read_json(args.exceptions)) + args.output.write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result, sort_keys=True)) + return 1 if result.get('status') == 'failed' else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/image/library/cybexos_managed_file.py b/image/library/cybexos_managed_file.py index 9a972865..0923bbc0 100644 --- a/image/library/cybexos_managed_file.py +++ b/image/library/cybexos_managed_file.py @@ -1,5 +1,6 @@ #!/usr/bin/python3 """Maintain vendor defaults only while their bytes still match our last write.""" +import fcntl import hashlib import json import os @@ -20,7 +21,19 @@ def atomic(path, data, mode=0o600): Path(temporary).unlink(missing_ok=True) -def manage(destination, content, ledger, absent=False, mode=0o644, check=False): +def manage(destination, content, ledger, absent=False, mode=0o644, check=False, baseline=None): + if check: + return _manage(destination, content, ledger, absent, mode, check, baseline) + ledger = Path(ledger) + ledger.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + # Login seeding and an explicit converge can target the same account. + with ledger.with_name(ledger.name + '.lock').open('a') as lock: + os.fchmod(lock.fileno(), 0o600) + fcntl.flock(lock, fcntl.LOCK_EX) + return _manage(destination, content, ledger, absent, mode, check, baseline) + + +def _manage(destination, content, ledger, absent=False, mode=0o644, check=False, baseline=None): """Unknown/custom files and symlinks are never adopted or overwritten. Persist ownership before publishing new bytes, recording both old and new @@ -39,6 +52,8 @@ def manage(destination, content, ledger, absent=False, mode=0o644, check=False): current = destination.read_bytes() if destination.exists() else None digest = hashlib.sha256(current).hexdigest() if current is not None else None desired = None if absent else hashlib.sha256(content).hexdigest() + if baseline is not None and current == baseline: + old = [digest] if digest is not None and digest != desired and digest not in old: return {'changed': False, 'preserved': True} # A user deletion is an override once we have adopted an existing file. @@ -71,14 +86,18 @@ def main(): module = AnsibleModule(argument_spec={ 'dest': {'type': 'path', 'required': True}, 'content': {'type': 'str', 'default': ''}, + 'ledger': {'type': 'path', 'default': '/var/lib/cybexos/reconcile/managed-files.json'}, + 'baseline': {'type': 'str', 'default': None}, 'state': {'choices': ['present', 'absent'], 'default': 'present'}, 'mode': {'type': 'str', 'default': '0644'}, }, supports_check_mode=True) try: result = manage(module.params['dest'], module.params['content'].encode(), - '/var/lib/cybexos/reconcile/managed-files.json', + module.params['ledger'], absent=module.params['state'] == 'absent', - mode=int(module.params['mode'], 8), check=module.check_mode) + mode=int(module.params['mode'], 8), check=module.check_mode, + baseline=(module.params['baseline'].encode() + if module.params['baseline'] is not None else None)) except (OSError, ValueError) as error: module.fail_json(msg=str(error)) module.exit_json(**result) diff --git a/image/library/cybexos_user_include.py b/image/library/cybexos_user_include.py new file mode 100644 index 00000000..5e81a664 --- /dev/null +++ b/image/library/cybexos_user_include.py @@ -0,0 +1,129 @@ +#!/usr/bin/python3 +"""Place vendor defaults below explicit user choices in each application's order.""" +import hashlib +import os +from pathlib import Path +import subprocess +import tempfile + +BEGIN = '# BEGIN CYBEXOS MANAGED INCLUDE' +END = '# END CYBEXOS MANAGED INCLUDE' +BLOCKS = { + 'kitty': 'include cybexos.conf', + 'git': '[include]\n path = ~/.config/cybexos/gitconfig', + # Reset the final user Host/Match scope before the fallback Include. + 'ssh': 'Host *\n Include ~/.config/cybexos/ssh.conf', +} + + +def render(text, kind, absent=False): + """Only move our exact include; a user-edited managed block is theirs.""" + lines = text.splitlines(keepends=True) + begins = [i for i, line in enumerate(lines) if line.rstrip('\r\n') == BEGIN] + ends = [i for i, line in enumerate(lines) if line.rstrip('\r\n') == END] + if begins or ends: + if len(begins) != 1 or len(ends) != 1 or ends[0] <= begins[0]: + return text, True + start, end = begins[0], ends[0] + body = '\n'.join(line.strip() for line in lines[start + 1:end]) + accepted = ['\n'.join(line.strip() for line in BLOCKS[kind].splitlines())] + if kind == 'ssh': + accepted.append('Include ~/.ssh/config.d/cybexos.conf') + if body not in accepted: + return text, True + lines = lines[:start] + lines[end + 1:] + remaining = ''.join(lines) + if absent: + return remaining, False + block = BEGIN + '\n' + BLOCKS[kind] + '\n' + END + '\n' + if kind == 'ssh': + # OpenSSH keeps the first obtained value. Kitty/Git scalar values use + # the last one, so they put vendor defaults at the beginning instead. + return remaining + ('\n' if remaining and not remaining.endswith('\n') else '') + block, False + return block + remaining, False + + +def update(path, kind, absent=False, check=False): + path = Path(path) + if path.is_symlink() or any(parent.is_symlink() for parent in path.parents): + return {'changed': False, 'preserved': True} + if path.exists() and not path.is_file(): + return {'changed': False, 'preserved': True} + original = path.read_bytes() if path.exists() else b'' + try: + desired, preserved = render(original.decode(), kind, absent) + except UnicodeDecodeError: + return {'changed': False, 'preserved': True} + desired = desired.encode() + if preserved or desired == original: + return {'changed': False, 'preserved': preserved} + if check: + return {'changed': True, 'preserved': False} + path.parent.mkdir(parents=True, exist_ok=True) + if original: + backup = path.with_name(path.name + '.cybexos-before-' + hashlib.sha256(original).hexdigest()[:16]) + try: + with backup.open('xb') as stream: + os.fchmod(stream.fileno(), 0o600) + stream.write(original) + except FileExistsError: + pass + fd, temporary = tempfile.mkstemp(prefix='.cybexos-include-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + os.fchmod(stream.fileno(), path.stat().st_mode & 0o777 if path.exists() + else (0o600 if kind == 'ssh' else 0o644)) + stream.write(desired) + stream.flush() + os.fsync(stream.fileno()) + # Do not replace edits made while preparing the include. + if (path.read_bytes() if path.exists() else b'') != original: + return {'changed': False, 'preserved': True} + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + return {'changed': True, 'preserved': False} + + +def personal_git_credentials(path): + """Git credential helpers accumulate, unlike normal last-value settings. + + Respect personal helper chains (including nested includes) by omitting + vendor authentication defaults whenever the account has its own helper. + Only a boolean crosses the Ansible boundary, never credential commands. + """ + path = Path(path) + if not path.exists(): + return False + result = subprocess.run(['git', 'config', '--file', str(path), '--includes', + '--show-origin', '--get-regexp', r'^credential(\..*)?\.helper$'], + capture_output=True, text=True, timeout=10, check=False) + if result.returncode not in (0, 1): + raise ValueError('Could not read personal Git credential configuration') + vendor = path.parent / '.config/cybexos/gitconfig' + return any(line.split('\t', 1)[0] != 'file:' + str(vendor) + for line in result.stdout.splitlines()) + + +def main(): + from ansible.module_utils.basic import AnsibleModule + module = AnsibleModule(argument_spec={ + 'path': {'type': 'path', 'required': True}, + 'kind': {'choices': list(BLOCKS), 'required': True}, + 'state': {'choices': ['present', 'absent'], 'default': 'present'}, + 'inspect_git_credentials': {'type': 'bool', 'default': False}, + }, supports_check_mode=True) + try: + if module.params['inspect_git_credentials']: + result = {'changed': False, + 'personal_credentials': personal_git_credentials(module.params['path'])} + else: + result = update(module.params['path'], module.params['kind'], + absent=module.params['state'] == 'absent', check=module.check_mode) + except (OSError, ValueError, subprocess.TimeoutExpired) as error: + module.fail_json(msg=str(error)) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/image/package b/image/package index 17b8de36..51cb5a63 100755 --- a/image/package +++ b/image/package @@ -65,6 +65,7 @@ def main(): runtime = "usr/share/cybexos/runtime" copy("LICENSE", "usr/share/licenses/cybexos-desktop/LICENSE") + copy("release-manifest.json", "usr/share/cybexos/release-manifest.json") quickshell = ROOT / "roles/desktop/files/quickshell" for source in sorted(quickshell.rglob("*")): if not source.is_file() or source.is_symlink() or "__pycache__" in source.parts or source.suffix == ".pyc": @@ -74,7 +75,7 @@ def main(): if source.suffix in (".qml", ".js"): target = payload / relative target.write_text(target.read_text().replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - for name in ("hyprland.lua", "bindings.lua", "autostart.lua", "displays.lua"): + for name in ("hyprland.lua", "bindings.lua", "autostart.lua", "displays.lua", "input_preferences.lua"): content = (ROOT / "roles/desktop/files" / name).read_text() content = content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-") write(f"{runtime}/hypr/{name}", content) @@ -126,11 +127,27 @@ def main(): hermes = environment.from_string((ROOT / "roles/desktop/templates/hermes-menubar-bridge.service.j2").read_text()).render(primary_home="%h", hermes_bridge_executable="/usr/libexec/cybexos-hermes-menubar-bridge") write("usr/lib/systemd/user/hermes-menubar-bridge.service", hermes) copy("roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py", "usr/libexec/cybexos-hermes-menubar-bridge", True) + for source in sorted((ROOT / "roles/desktop/files/hermes-menubar-bridge/cybex_hermes").glob("*.py")): + copy(source.relative_to(ROOT), f"usr/libexec/cybex_hermes/{source.name}") # Voxtype's RPM owns its system user unit. The session target starts that # unit; do not collide with it by packaging our per-user Ansible template. write(f"{seed}/.npmrc", "prefix=${HOME}/.npm-global\n") write("usr/share/cybexos/applications.json", json.dumps(applications, indent=2) + "\n") copy("roles/base/files/cybexos-system-snapshot", "usr/libexec/cybexos-system-snapshot", True) + for name in ("cybexos-update-transaction", "cybexos-update-bootstrap", "cybexos-update-recover", "cybexos-vendor-paths.json", "cybexos-major-upgrade"): + copy(f"roles/base/files/{name}", f"usr/libexec/{name}", not name.endswith('.json')) + for name in ("cybexos-update-recover.service", "cybexos-update-recover-login.conf"): + copy(f"roles/base/files/{name}", f"usr/libexec/{name}") + recover = (ROOT / "roles/base/files/cybexos-update-recover.service").read_text() + write("usr/lib/systemd/system/cybexos-update-recover.service", + recover.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) + for unit in ("systemd-user-sessions.service", "sddm.service"): + copy("roles/base/files/cybexos-update-recover-login.conf", + f"usr/lib/systemd/system/{unit}.d/60-cybexos-update-recover.conf") + for name in ("cybexos-major-upgrade-validate.service", "cybexos-major-upgrade-validate.timer"): + content = (ROOT / "roles/base/files" / name).read_text() + write(f"usr/lib/systemd/system/{name}", + content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) # `sudo cybexos-system-snapshot ...`, as documented; libexec is not on PATH. (payload / "usr/bin").mkdir(parents=True, exist_ok=True) (payload / "usr/bin/cybexos-system-snapshot").symlink_to("../libexec/cybexos-system-snapshot") @@ -174,6 +191,9 @@ def main(): copy("roles/desktop/files/cybexos-input-method.service", "usr/lib/systemd/user/cybexos-input-method.service") copy("roles/desktop/files/90-cybexos-ibus.gschema.override", "usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override") + # Share the Files/GVfs default with checkout installations. + copy("roles/desktop/files/90-cybexos-smb.gschema.override", + "usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override") for name in ("brave-browser", "1password", "chatgpt", "tailscale"): copy(f"roles/apps/files/{name}.repo", f"usr/share/cybexos/repository-policy/{name}.repo") # The fonts are already checksum-pinned by the project. Preserve upstream diff --git a/image/parity-exceptions.json b/image/parity-exceptions.json new file mode 100644 index 00000000..4967e217 --- /dev/null +++ b/image/parity-exceptions.json @@ -0,0 +1,9 @@ +{ + "iso": { + "cybexos-desktop.x86_64": "The ISO delivers the shared desktop payload as an RPM; checkout installs the same source through Ansible." + }, + "checkout": { + "cloud-init.noarch": "The pinned Fedora Cloud base uses cloud-init only to provision this disposable qualification guest.", + "cloud-utils-growpart.noarch": "The pinned Fedora Cloud base expands only its disposable virtual disk during first boot." + } +} diff --git a/image/parity_qualification.py b/image/parity_qualification.py new file mode 100644 index 00000000..1214644c --- /dev/null +++ b/image/parity_qualification.py @@ -0,0 +1,59 @@ +"""Shared real-desktop and saved-preference checks for both installer paths.""" +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parents[1] + + +def desktop_lifecycle(vm): + helper = (ROOT / 'tests/lib/quickshell-live').read_text() + test = (ROOT / 'tests/system-settings-live').read_text().split('qs_live_begin\n', 1)[1] + script = 'set -euo pipefail\nexport XDG_RUNTIME_DIR=/run/user/$(id -u)\n' + helper + '\nqs_live_begin\n' + test + try: + subprocess.run([*vm.ssh, 'bash -s'], input=script, text=True, capture_output=True, check=True, timeout=300) + except subprocess.CalledProcessError as error: + detail = ((error.stdout or '') + (error.stderr or ''))[-4000:] + raise RuntimeError('Managed Settings lifecycle failed: ' + detail) from error + + +SAVED_CHOICES = r''' +import json, os, pwd +from pathlib import Path +account=pwd.getpwnam('qualification') +home=Path(account.pw_dir) +path=home / '.config/cybexos/shell.json' +data=json.loads(path.read_text()) +data['position']='bottom' +data['qualificationFuture']={'preserve':[False,0,'user-owned']} +path.write_text(json.dumps(data,indent=2)+'\n') +files={ + '.config/cybexos/input.json': json.dumps({'v':1,'keyboard':{'layouts':[{'layout':'us','variant':''},{'layout':'nl','variant':''}], 'shortcut':'grp:alt_shift_toggle'}, 'touchpad':{'tap':False,'naturalScroll':False,'sensitivity':0.25},'qualificationFuture':True})+'\n', + '.config/cybexos/hypr/user.lua': 'hl.config({ input = { repeat_rate = 37 } })\n', + 'qualification-personal-marker': 'qualification-preserve\n', +} +for relative, value in files.items(): + target=home / relative + target.parent.mkdir(parents=True,exist_ok=True) + target.write_text(value) + os.chown(target,account.pw_uid,account.pw_gid) +os.chown(path,account.pw_uid,account.pw_gid) +''' + + +def prepare_saved_choices(vm, password, root_script): + root_script(vm, "python3 - <<'PY'\n" + SAVED_CHOICES + '\nPY\n', password) + + +def verify_saved_choices(vm, password, root_script): + root_script(vm, r'''python3 - <<'PY' +import json +from pathlib import Path +home=Path('/home/qualification') +data=json.loads((home / '.config/cybexos/shell.json').read_text()) +assert data['position']=='bottom' +assert data['qualificationFuture']=={'preserve':[False,0,'user-owned']} +assert (home / '.config/cybexos/hypr/user.lua').read_text()=='hl.config({ input = { repeat_rate = 37 } })\n' +assert json.loads((home / '.config/cybexos/input.json').read_text())['qualificationFuture'] is True +assert (home / 'qualification-personal-marker').read_text()=='qualification-preserve\n' +PY +''', password) diff --git a/image/provision_payload.py b/image/provision_payload.py index a1c476a9..888e0e46 100644 --- a/image/provision_payload.py +++ b/image/provision_payload.py @@ -11,7 +11,8 @@ def prepare_provision(root, payload): 'roles/dotfiles/files/fish-config.fish', 'roles/dotfiles/tasks/environment.yml', 'roles/dotfiles/templates/environment.conf.j2', 'roles/dotfiles/tasks/personal.yml', 'roles/dotfiles/files/kitty.conf', - 'roles/dotfiles/files/manage-firefox-policy', + 'roles/dotfiles/files/manage-firefox-policy', 'roles/dotfiles/files/ssh.conf', + 'roles/dotfiles/files/gitconfig', 'roles/dotfiles/tasks/agent-skills.yml', 'scripts/manage-agent-skills', 'roles/desktop/tasks/portals.yml'): source = root / relative diff --git a/image/qualification.py b/image/qualification.py index eeaff41f..d0519ca6 100644 --- a/image/qualification.py +++ b/image/qualification.py @@ -11,6 +11,8 @@ from build_support import atomic_json, digest from browser_qualification import browser_dependencies, qualify_browser from login_qualification import qualify_login +from installed_outcomes import capture_guest +from parity_qualification import desktop_lifecycle, prepare_saved_choices, verify_saved_choices from upgrade_qualification import (create_recovery_point, prepare_user_choices, select_recovery_boot, upgrade, verify_recovery_boot, verify_restored, verify_user_choices) from vm_testing import QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL, TestVM, poweroff_guest, require_test_iso, run @@ -244,11 +246,14 @@ def main(): parser.add_argument('--erase-disposable-disk', action='store_true', help='Explicitly allow installation onto the new task-owned virtual disk') parser.add_argument('--keep-artifacts', action='store_true', help='Retain task-owned disk/logs for unresolved diagnostics') parser.add_argument('--install-timeout', type=int, default=1800) + parser.add_argument('--capture-outcomes', action='store_true', help='Capture fresh and saved-choice outcomes for same-source checkout comparison') args = parser.parse_args() if not args.execute_vm or not args.erase_disposable_disk: parser.error('qualification requires --execute-vm --erase-disposable-disk; no VM or installer starts without both') if args.recovery_check and not args.candidate_rpm: parser.error('--recovery-check requires --candidate-rpm') + if args.capture_outcomes and (args.candidate_rpm or args.legacy_installer): + parser.error('Outcome parity capture requires the fresh candidate ISO') iso = require_test_iso(args.iso) if args.candidate_rpm and (args.candidate_rpm.is_symlink() or not args.candidate_rpm.is_file() or args.candidate_rpm.suffix != '.rpm'): @@ -341,6 +346,25 @@ def main(): verify_restored(vm, versions['installed'], password, root_script) verify_user_choices(vm, password, root_script, preference) report['checks'].append('recovery-boot-restore') + if args.capture_outcomes: + desktop_lifecycle(vm) + fresh = capture_guest(vm, root_script, password, installation='iso', scenario=args.scenario, + profile='fresh', manifest='/usr/share/cybexos/applications.json', + provenance='/usr/share/cybexos/build.json') + atomic_json(args.output / 'outcomes-fresh.json', fresh) + prepare_saved_choices(vm, password, root_script) + root_script(vm, '/usr/libexec/cybexos-configure-installed --user qualification\n', password, timeout=1800) + poweroff_installed(vm, password) + boot_installed(vm, password, encrypted) + verify_saved_choices(vm, password, root_script) + desktop_lifecycle(vm) + saved = capture_guest(vm, root_script, password, installation='iso', scenario=args.scenario, + profile='saved', manifest='/usr/share/cybexos/applications.json', + provenance='/usr/share/cybexos/build.json') + atomic_json(args.output / 'outcomes-saved.json', saved) + report['source_revision'] = fresh['source_revision'] + report['source_content_sha256'] = fresh['source_content_sha256'] + report['checks'] += ['installed-outcomes-captured', 'saved-choice-reconfiguration', 'native-settings-lifecycle'] # Clear the temporary test access before stopping the disposable disk. vm.audit(applications=False) poweroff_guest(vm, password, root_script, timeout=60, cleanup_script=( diff --git a/image/qualify-checkout b/image/qualify-checkout new file mode 100755 index 00000000..ae1c4465 --- /dev/null +++ b/image/qualify-checkout @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Install the exact ISO source on pinned Fedora, boot its desktop, compare outcomes.""" +import argparse +import json +import os +from pathlib import Path +import secrets +import shlex +import shutil +import signal +import subprocess +import tarfile + +from build_support import atomic_json, create_seed, digest, checksum_entries +from download_cache import cached_download +from installed_outcomes import capture_guest, compare, read_json +from parity_qualification import desktop_lifecycle, prepare_saved_choices, verify_saved_choices +from qualification import SCENARIOS, boot_installed, poweroff_installed, root_script +from source_snapshot import archive_identity +from vm_testing import TestVM, run + +ROOT = Path(__file__).resolve().parents[1] +IMAGE = 'Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2' +IMAGE_URL = 'https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/' + IMAGE +IMAGE_SHA256 = '28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f' +SOURCE = '/home/qualification/cybexos-source' + + +def verify_inputs(artifacts, iso_results, scenario): + checksum_entries(artifacts) + archive = artifacts / 'source.tar.gz' + identity = archive_identity(archive) + provenance = read_json(artifacts / 'build-provenance.json') + if any(provenance.get(key) != value for key, value in identity.items()): + raise ValueError('Source archive does not match the candidate ISO provenance') + with tarfile.open(archive, 'r:gz') as stream: + embedded = json.load(stream.extractfile('image/build-provenance.json')) + if embedded != provenance: + raise ValueError('Source archive embeds different build provenance') + qualification = read_json(iso_results / 'qualification.json') + isos = list(artifacts.glob('*.iso')) + if (len(isos) != 1 or qualification.get('status') != 'passed' + or qualification.get('scenario') != scenario or qualification.get('iso_sha256') != digest(isos[0])): + raise ValueError('Checkout parity requires the exact candidate ISO qualification') + for profile in ('fresh', 'saved'): + result = read_json(iso_results / ('outcomes-' + profile + '.json')) + if (result.get('source_content_sha256') != identity['source_content_sha256'] or + result.get('source_revision') != provenance.get('source_revision') or + result.get('scenario') != scenario or result.get('profile') != profile or result.get('installation') != 'iso'): + raise ValueError('ISO outcomes and checkout source are not from the same build') + return archive, provenance + + +def cloud_seed(vm, password): + # Only a salted password hash is placed in cloud-init; the plaintext stays + # in memory/stdin and never appears in argv, reports, logs or screenshots. + encoded = run(['openssl', 'passwd', '-6', '-stdin'], input=password + '\n', text=True, capture_output=True).stdout.strip() + config = {'users': [{'name': 'qualification', 'groups': ['wheel'], 'shell': '/bin/bash', + 'sudo': 'ALL=(ALL) NOPASSWD:ALL', 'lock_passwd': False, 'passwd': encoded, + 'ssh_authorized_keys': [vm.key.with_suffix('.pub').read_text().strip()]}], + 'ssh_pwauth': False, 'disable_root': True, 'preserve_hostname': True, + 'runcmd': [['hostnamectl', 'set-hostname', 'cybexos-qualification'], + ['loginctl', 'enable-linger', 'qualification'], + ['systemctl', 'enable', '--now', 'sshd.service']]} + (vm.work / 'user-data').write_text('#cloud-config\n' + json.dumps(config)) + (vm.work / 'meta-data').write_text('instance-id: cybexos-parity-' + secrets.token_hex(8) + '\n') + create_seed(vm.work, 'cloud-localds') + vm.seed = vm.work / 'seed.iso' + + +def install_script(scenario, *, repeat=False): + encrypted, keyboard, locale, timezone = SCENARIOS[scenario] + if encrypted: + raise ValueError('Pinned cloud parity scenarios are plaintext; encrypted ISO qualification remains separate') + initial = '' if repeat else f''' +dnf install -y ansible-core git python3-pyyaml firewalld glibc-langpack-en glibc-langpack-nl +localectl set-locale LANG={shlex.quote(locale)} +timedatectl set-timezone {shlex.quote(timezone)} +localectl set-x11-keymap {shlex.quote(keyboard)} +''' + return f'''set -euo pipefail +export ANSIBLE_FORCE_COLOR=0 PYTHONDONTWRITEBYTECODE=1 +export SUDO_USER=qualification +{initial} +export LANG={shlex.quote(locale)} +cd {SOURCE} +# Keep the established transport alive through strict firewall convergence; +# the new runtime SSH exception is test-only and disappears on reboot. +cleanup() {{ firewall-cmd --zone=cybexos --add-service=ssh >/dev/null 2>&1 || true; }} +trap cleanup EXIT +./install --non-interactive +rm -f /etc/sudoers.d/90-cloud-init-users +systemctl set-default graphical.target +systemctl enable sddm.service +cleanup +trap - EXIT +''' + + +def install(vm, scenario, password, *, repeat=False): + # Use the public installer's supported sudo invocation. SUDO_USER selects + # the account; install derives its actual home through getent, not root's. + try: + root_script(vm, install_script(scenario, repeat=repeat), password, timeout=7200) + except subprocess.CalledProcessError as error: + detail = ((error.stdout or '') + (error.stderr or '')).replace(password, '[redacted]')[-6000:] + raise RuntimeError('Full checkout installation failed: ' + detail) from error + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--artifacts', required=True, type=Path) + parser.add_argument('--iso-results', required=True, type=Path) + parser.add_argument('--scenario', choices=('plain-us', 'plain-nl'), required=True) + parser.add_argument('--output', required=True, type=Path) + parser.add_argument('--cache', type=Path, default=Path(os.environ.get('XDG_CACHE_HOME', str(Path.home() / '.cache'))) / 'cybexos/image/base') + parser.add_argument('--execute-vm', action='store_true') + parser.add_argument('--keep-artifacts', action='store_true') + args = parser.parse_args() + if not args.execute_vm: + parser.error('--execute-vm explicitly authorizes new disposable VM disks and a complete guest installation') + for signal_number in (signal.SIGTERM, signal.SIGHUP): + signal.signal(signal_number, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) + for executable in ('cloud-localds', 'openssl'): + if not shutil.which(executable): + raise RuntimeError('Missing checkout qualification prerequisite: ' + executable) + archive, provenance = verify_inputs(args.artifacts, args.iso_results, args.scenario) + base = cached_download(IMAGE_URL, IMAGE_SHA256, args.cache) + vm = TestVM(args.output, network_restricted=False) + password = secrets.token_hex(24) + report = {'status': 'failed', 'scenario': args.scenario, 'source_revision': provenance['source_revision'], + 'source_content_sha256': provenance['source_content_sha256'], 'source_archive_sha256': digest(archive), + 'fedora_cloud_sha256': IMAGE_SHA256, 'checks': [], + 'scope': 'Full checkout installation and graphical session on disposable QEMU Fedora Cloud; no physical hardware qualification'} + try: + vm.prepare() + vm.disk.unlink() # Only the disk just created by this owned TestVM. + run(['qemu-img', 'create', '-q', '-f', 'qcow2', '-F', 'qcow2', '-b', str(base), str(vm.disk), '100G']) + cloud_seed(vm, password) + vm.start(user='qualification') + vm.wait_ssh(timeout=600, setup=False, redactions=(password,)) + run([*vm.ssh, 'cloud-init status --wait --format=json'], text=True, capture_output=True, timeout=600) + run([*vm.ssh, f'mkdir -p {SOURCE}'], timeout=20) + with archive.open('rb') as source: + run([*vm.ssh, f'tar xzf - -C {SOURCE}'], stdin=source, capture_output=True, timeout=120) + with (args.artifacts / 'applications.json').open('rb') as manifest: + run([*vm.ssh, 'cat > /home/qualification/cybexos-applications.json'], stdin=manifest, timeout=30) + install(vm, args.scenario, password) + report['checks'].append('full-checkout-installation') + # The first shutdown happens before a graphical session is necessarily + # ready; use the authenticated poweroff helper, not TestVM.stop's audit. + poweroff_installed(vm, password) + vm.seed = None + boot_installed(vm, password, False) + for profile in ('fresh', 'saved'): + if profile == 'saved': + prepare_saved_choices(vm, password, root_script) + install(vm, args.scenario, password, repeat=True) + poweroff_installed(vm, password) + boot_installed(vm, password, False) + verify_saved_choices(vm, password, root_script) + vm.audit(applications=False) + desktop_lifecycle(vm) + result = capture_guest(vm, root_script, password, installation='checkout', scenario=args.scenario, + profile=profile, manifest='/home/qualification/cybexos-applications.json', + provenance=SOURCE + '/image/build-provenance.json') + atomic_json(args.output / ('outcomes-' + profile + '.json'), result) + comparison = compare(read_json(args.iso_results / ('outcomes-' + profile + '.json')), result, + read_json(ROOT / 'image/parity-exceptions.json')) + atomic_json(args.output / ('parity-' + profile + '.json'), comparison) + if comparison['status'] != 'passed': + raise RuntimeError('Installed outcome mismatch; inspect parity-' + profile + '.json. No differences were auto-approved.') + report['checks'].append('installed-parity-' + profile) + report['checks'] += ['same-source-content', 'native-settings-lifecycle', 'saved-choice-reconfiguration', 'full-graphical-session'] + poweroff_installed(vm, password) + report['status'] = 'passed' + except BaseException as error: + report['error'] = (str(error) or type(error).__name__).replace(password, '[redacted]') + raise + finally: + if vm.owned: + try: + vm.cleanup(args.keep_artifacts) + finally: + for name in ('user-data', 'meta-data', 'seed.iso'): + (vm.work / name).unlink(missing_ok=True) + atomic_json(args.output / 'checkout-qualification.json', report) + print(json.dumps(report)) + + +if __name__ == '__main__': + main() diff --git a/image/release-gate b/image/release-gate index 23422571..720a208b 100755 --- a/image/release-gate +++ b/image/release-gate @@ -87,8 +87,34 @@ def main(): report['testing_iso'] = str(iso) report['testing_iso_bytes'] = iso.stat().st_size for scenario in ('encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl'): - run_child([str(ROOT / 'image/qualify'), str(iso), '--output', str(output / scenario), - '--execute-vm', '--erase-disposable-disk', '--scenario', scenario]) + command = [str(ROOT / 'image/qualify'), str(iso), '--output', str(output / scenario), + '--execute-vm', '--erase-disposable-disk', '--scenario', scenario] + if scenario.startswith('plain-'): + command.append('--capture-outcomes') + run_child(command) + report['installation_parity'] = [] + for scenario in ('plain-us', 'plain-nl'): + directory = output / ('checkout-' + scenario) + run_child([str(ROOT / 'image/qualify-checkout'), '--artifacts', str(artifacts), + '--iso-results', str(output / scenario), '--scenario', scenario, + '--output', str(directory), '--execute-vm']) + checkout = json.loads((directory / 'checkout-qualification.json').read_text()) + fresh = json.loads((output / scenario / 'qualification.json').read_text()) + if (checkout.get('status') != 'passed' or checkout.get('scenario') != scenario + or not checkout.get('source_content_sha256') + or checkout['source_content_sha256'] != fresh.get('source_content_sha256') + or checkout.get('source_revision') != fresh.get('source_revision')): + raise RuntimeError('Checkout parity did not qualify the exact ISO source') + for profile in ('fresh', 'saved'): + parity = json.loads((directory / ('parity-' + profile + '.json')).read_text()) + if (parity.get('status') != 'passed' or parity.get('source_content_sha256') != checkout['source_content_sha256'] + or parity.get('scenario') != scenario or parity.get('profile') != profile): + raise RuntimeError('Missing or mismatched real installed parity result') + fresh['checkout_parity'] = checkout + fresh['checks'] += ['installed-parity-fresh', 'installed-parity-saved'] + atomic_json(output / scenario / 'qualification.json', fresh) + report['installation_parity'].append({'scenario': scenario, 'source_content_sha256': checkout['source_content_sha256'], + 'checkout_report_sha256': digest(directory / 'checkout-qualification.json')}) run_child([str(ROOT / 'image/qualify'), str(baseline), '--output', str(output / 'upgrade'), '--execute-vm', '--erase-disposable-disk', '--scenario', 'encrypted-us', '--candidate-rpm', str(rpms[0]), '--recovery-check', '--legacy-installer']) diff --git a/image/rootfs/usr/bin/cybex b/image/rootfs/usr/bin/cybex index 5277b84a..f1b6af6b 100755 --- a/image/rootfs/usr/bin/cybex +++ b/image/rootfs/usr/bin/cybex @@ -6,11 +6,12 @@ case $command_name in welcome) exec /usr/bin/cybexos-welcome "$@" ;; configure) exec /usr/libexec/cybexos-config "$@" ;; update) exec /usr/share/cybexos/bin/cybexos-update-run run "$@" ;; + upgrade-system) exec sudo /usr/libexec/cybexos-major-upgrade "$@" ;; update-channel) exec /usr/libexec/cybexos-update-channel "$@" ;; prepare-apps) exec /usr/libexec/cybexos-user-init --background "$@" ;; repair) exec sudo /usr/libexec/cybexos-configure-installed --user "${SUDO_USER:-$USER}" "$@" ;; agent) exec /usr/share/cybexos/bin/cybexos-agent "$@" ;; - plugin|dev) exec /usr/share/cybexos/bin/cybexos-runtime "$command_name" "$@" ;; + plugin|dev|shell) exec /usr/share/cybexos/bin/cybexos-runtime "$command_name" "$@" ;; version|--version) exec rpm -q cybexos-desktop ;; verify|doctor) exec /usr/libexec/cybexos-doctor "$@" ;; uninstall) @@ -29,11 +30,13 @@ Commands: welcome Open the CybexOS welcome window configure Change installation choices and apply them (--check prints them) update Check for and apply CybexOS and system updates + upgrade-system Prepare, perform, or inspect a supported Fedora major upgrade update-channel Inspect or enroll the desktop RPM update channel prepare-apps Prepare the offline applications for this account repair Reapply the installed-system policy to this account agent Launch or choose the default AI coding agent plugin Install, update, clone, remove, or configure desktop plugins + shell Inspect shell health, enter safe mode, or retry the full desktop dev Select, inspect, or disable a live development checkout version Print the installed CybexOS package version verify Check installed-system health (--json for machine-readable output) diff --git a/image/source_snapshot.py b/image/source_snapshot.py new file mode 100644 index 00000000..eb0188f8 --- /dev/null +++ b/image/source_snapshot.py @@ -0,0 +1,64 @@ +"""Exact reviewed checkout content shared by image and checkout qualification.""" +import hashlib +import json +from pathlib import Path, PurePosixPath +import subprocess +import tarfile + +GENERATED = {'image/update-channel.json', 'image/update-key.asc', 'image/build-provenance.json'} + + +def content_digest(records): + return hashlib.sha256(json.dumps(records, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def source_files(root): + result = subprocess.run(['git', '-C', str(root), 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], + capture_output=True, check=True) + paths = [] + for name in sorted(set(result.stdout.decode().split('\0')) - {''} - GENERATED): + path = Path(root) / name + if '__pycache__' in path.parts or path.suffix == '.pyc': + continue + if path.is_symlink(): + raise ValueError(f'Source snapshot must contain regular files, not symlinks: {name}') + if path.is_file(): + paths.append((name, path)) + return paths + + +def tree_identity(root): + records = [{'path': name, 'executable': bool(path.stat().st_mode & 0o111), + 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for name, path in source_files(root)] + return {'source_content_sha256': content_digest(records), 'source_file_count': len(records)} + + +def write_archive(root, destination, additions=None): + with tarfile.open(destination, 'w:gz') as archive: + for name, path in source_files(root): + archive.add(path, arcname=name, recursive=False) + for name, path in (additions or {}).items(): + if name not in GENERATED: + raise ValueError('Unexpected generated source input') + archive.add(path, arcname=name, recursive=False) + + +def archive_identity(path): + """Validate before extraction: no traversal, duplicate names or links.""" + records, seen = [], set() + with tarfile.open(path, 'r:gz') as archive: + for member in archive: + name = member.name + if (not member.isfile() or PurePosixPath(name).is_absolute() or '..' in PurePosixPath(name).parts + or name in seen or name.startswith('./') or '\\' in name): + raise ValueError('Unsafe or duplicate source archive entry') + seen.add(name) + if name not in GENERATED: + stream = archive.extractfile(member) + records.append({'path': name, 'executable': bool(member.mode & 0o111), + 'sha256': hashlib.file_digest(stream, 'sha256').hexdigest()}) + for required in ('install', 'site.yml', 'inventory/group_vars/all.yml', 'image/build-provenance.json'): + if required not in seen: + raise ValueError(f'Source archive lacks required input: {required}') + records.sort(key=lambda item: item['path']) + return {'source_content_sha256': content_digest(records), 'source_file_count': len(records)} diff --git a/image/test_display_policy.py b/image/test_display_policy.py new file mode 100644 index 00000000..a6278000 --- /dev/null +++ b/image/test_display_policy.py @@ -0,0 +1,128 @@ +"""Execute the shared XPS display tasks against disposable EDIDs and kernels.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +import yaml + +from provision_payload import prepare_provision + + +ROOT = Path(__file__).resolve().parents[1] +GRUBBY = '''#!/usr/bin/python3 +import json, os, sys +from pathlib import Path +root = Path(os.environ['DISPLAY_FIXTURE']) +state = root / 'kernels.json' +entries = json.loads(state.read_text()) +with (root / 'grubby.jsonl').open('a') as log: + log.write(json.dumps(sys.argv[1:]) + '\\n') +if sys.argv[1:] == ['--info=ALL']: + for entry in entries: + print('args="' + entry + '"') +elif sys.argv[1:] == ['--update-kernel=ALL', '--args=xe.enable_psr=0']: + if os.environ.get('DISPLAY_IGNORE_UPDATE') != '1': + entries = [' '.join([arg for arg in entry.split() + if not arg.startswith('xe.enable_psr=')] + ['xe.enable_psr=0']) + for entry in entries] + state.write_text(json.dumps(entries)) +else: + raise SystemExit('unexpected grubby invocation: ' + repr(sys.argv)) +''' + + +class PanelRefreshParity(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix='cybex-panel-refresh.') + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + payload = self.root / 'payload' + prepare_provision(ROOT, payload) + self.sources = (ROOT, payload / 'usr/share/cybexos/provision') + for relative in ('roles/xps-2026/tasks/main.yml', 'roles/xps-2026/tasks/display.yml', + 'roles/xps-2026/defaults/main.yml'): + self.assertEqual((self.sources[0] / relative).read_bytes(), + (self.sources[1] / relative).read_bytes()) + + def fixture(self, source, *, supported=True, sku='0DB9', edid='30e4c607', opt_out=False): + root = Path(tempfile.mkdtemp(dir=self.root)) + binaries = root / 'bin' + binaries.mkdir() + binary = binaries / 'grubby' + binary.write_text(GRUBBY) + binary.chmod(0o755) + panel = root / 'drm/card0-eDP-1/edid' + panel.parent.mkdir(parents=True) + if edid is not None: + panel.write_bytes(b'\x00\xff\xff\xff\xff\xff\xff\x00' + bytes.fromhex(edid) + bytes(116)) + entries = ['root=UUID=fixture quiet xe.enable_psr=0', + 'root=UUID=fixture quiet video=DP-2:d xe.enable_dpcd_backlight=1'] + (root / 'kernels.json').write_text(json.dumps(entries)) + # Substitute only the sysfs root. Run the actual probe, guard and + # convergence tasks; no host EDID, boot entry or privilege is used. + display = root / 'display.yml' + display.write_text((source / 'roles/xps-2026/tasks/display.yml').read_text() + .replace('/sys/class/drm/', str(root / 'drm') + '/')) + main = yaml.safe_load((source / 'roles/xps-2026/tasks/main.yml').read_text()) + gate = dict(next(task for task in main if task.get('ansible.builtin.import_tasks') == 'display.yml')) + gate['ansible.builtin.import_tasks'] = str(display) + variables = yaml.safe_load((source / 'roles/xps-2026/defaults/main.yml').read_text()) + variables.update(xps_2026_is_supported=supported, xps_2026_product_sku=sku) + if opt_out: + variables['xps_2026_psr_disabled_panels'] = [] + playbook = root / 'playbook.yml' + playbook.write_text(yaml.safe_dump([{ + 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, 'become': False, + 'vars': variables, 'tasks': [gate], + 'environment': {'PATH': str(binaries) + ':/usr/bin:/bin', 'DISPLAY_FIXTURE': str(root), + 'DISPLAY_IGNORE_UPDATE': "{{ fixture_ignore_update | default('0') }}"}, + }])) + return root, entries + + def run_play(self, root, *arguments, succeeds=True): + result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(root / 'playbook.yml'), + *arguments], text=True, capture_output=True, timeout=30, + env={**os.environ, 'ANSIBLE_STDOUT_CALLBACK': 'default', + 'ANSIBLE_NOCOLOR': '1', 'ANSIBLE_FORCE_COLOR': '0'}) + self.assertEqual(result.returncode == 0, succeeds, result.stdout + result.stderr) + + def test_both_paths_preserve_arguments_and_update_all_kernels_once(self): + outcomes = [] + for source in self.sources: + with self.subTest(source=source): + root, before = self.fixture(source) + self.run_play(root, '--check') + self.assertEqual(json.loads((root / 'kernels.json').read_text()), before) + self.run_play(root) + self.run_play(root) + after = json.loads((root / 'kernels.json').read_text()) + self.assertEqual(after, [before[0], before[1] + ' xe.enable_psr=0']) + calls = [json.loads(line) for line in (root / 'grubby.jsonl').read_text().splitlines()] + self.assertEqual(sum('--update-kernel=ALL' in call for call in calls), 1) + outcomes.append(after) + self.assertEqual(outcomes[0], outcomes[1]) + + def test_other_panels_machines_missing_edid_and_saved_opt_out_are_untouched(self): + cases = ({'edid': '30e4c707'}, {'edid': '1234c607'}, {'edid': None}, + {'sku': '0DBA'}, {'supported': False}, {'opt_out': True}) + for source in self.sources: + for case in cases: + with self.subTest(source=source, case=case): + root, before = self.fixture(source, **case) + self.run_play(root) + self.assertEqual(json.loads((root / 'kernels.json').read_text()), before) + self.assertFalse((root / 'grubby.jsonl').exists()) + + def test_silent_boot_entry_update_failure_is_reported_on_both_paths(self): + for source in self.sources: + with self.subTest(source=source): + root, before = self.fixture(source) + self.run_play(root, '-e', 'fixture_ignore_update=1', succeeds=False) + self.assertEqual(json.loads((root / 'kernels.json').read_text()), before) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_github_release.py b/image/test_github_release.py index 3b111183..1df66ef5 100644 --- a/image/test_github_release.py +++ b/image/test_github_release.py @@ -80,12 +80,31 @@ def save_manifest(self): def report(self, scenario, iso, checks, rpm=None): path = self.root / (scenario + '.json') - path.write_text(json.dumps({'scenario': scenario, 'iso_sha256': iso, - 'status': 'passed', 'checks': checks, - 'candidate_rpm_sha256': rpm})) + report = {'scenario': scenario, 'iso_sha256': iso, 'status': 'passed', 'checks': checks, + 'candidate_rpm_sha256': rpm} + if scenario.startswith('plain-'): + report.update(source_revision='a' * 40, source_content_sha256='d' * 64, + checkout_parity={'status': 'passed', 'scenario': scenario, 'source_revision': 'a' * 40, + 'source_content_sha256': 'd' * 64, 'checks': ['full-checkout-installation', + 'installed-parity-fresh', 'installed-parity-saved', 'full-graphical-session']}) + report['checks'] += ['installed-parity-fresh', 'installed-parity-saved'] + path.write_text(json.dumps(report)) self.reports.append(path) return path + def test_checkout_parity_cannot_be_missing_stale_or_fixture_only(self): + target = self.root / 'plain-us.json' + original = json.loads(target.read_text()) + for replacement in ({}, {'status': 'passed', 'source_content_sha256': 'e' * 64}, + {**original['checkout_parity'], 'checks': ['source-fixtures']}, + {**original['checkout_parity'], 'source_revision': 'b' * 40}): + with self.subTest(replacement=replacement): + target.write_text(json.dumps({**original, 'checkout_parity': replacement})) + with self.assertRaisesRegex(ValueError, 'same-source real checkout/ISO parity'): + self.prepare() + self.assertFalse(self.output.exists()) + target.write_text(json.dumps(original)) + def prepare(self): # These fixtures are deliberately not signed releases or VM evidence. # Other tests cover the fail-closed signature subprocess boundary. diff --git a/image/test_installed_outcomes.py b/image/test_installed_outcomes.py new file mode 100644 index 00000000..f68c503f --- /dev/null +++ b/image/test_installed_outcomes.py @@ -0,0 +1,173 @@ +"""Fixtures for the real VM gate; these deliberately do not claim VM execution.""" +import importlib.machinery +import importlib.util +import io +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import Mock, patch + +import installed_outcomes as outcomes +import parity_qualification as parity +import source_snapshot as source + +loader = importlib.machinery.SourceFileLoader('checkout_qualification', str(Path(__file__).with_name('qualify-checkout'))) +spec = importlib.util.spec_from_loader(loader.name, loader) +checkout_runner = importlib.util.module_from_spec(spec) +loader.exec_module(checkout_runner) + + +def fixture(installation='iso'): + result = {key: {'fixture': True} for key in outcomes.CAPTURE_FIELDS} + result.update(format=1, installation=installation, scenario='plain-us', profile='fresh', + source_revision='a' * 40, source_content_sha256='b' * 64, hardware={'product_name': 'QEMU'}, + graphical_session=True, sole_managed_shell=True, installed_packages=['quickshell.x86_64=1'], + required_packages={'quickshell': ['quickshell.x86_64=1']}, flatpaks=[], selinux='Enforcing') + return result + + +class OutcomeComparison(unittest.TestCase): + def test_equal_real_capture_shapes_pass_without_implicit_exclusions(self): + result = outcomes.compare(fixture(), fixture('checkout'), {}) + self.assertEqual(result['status'], 'passed') + self.assertEqual(result['differences'], {}) + + def test_policy_or_effective_behavior_difference_blocks_release(self): + for key in ('settings', 'input', 'choices', 'authentication', 'firewall', 'system_units', + 'user_units', 'recovery', 'default_apps', 'required_packages'): + changed = fixture('checkout') + changed[key]['difference'] = False + with self.subTest(key=key): + result = outcomes.compare(fixture(), changed, {}) + self.assertEqual(result['status'], 'failed') + self.assertIn(key, result['differences']) + + def test_different_source_hardware_or_profile_cannot_be_compared(self): + for key, value in (('source_content_sha256', 'c' * 64), ('source_revision', 'd' * 40), + ('hardware', {'product_name': 'different'}), ('profile', 'saved'), ('scenario', 'plain-nl')): + changed = fixture('checkout') + changed[key] = value + with self.subTest(key=key), self.assertRaisesRegex(ValueError, 'mismatched ' + key): + outcomes.compare(fixture(), changed, {}) + + def test_sparse_fixture_or_no_live_session_is_not_installed_evidence(self): + for key in outcomes.CAPTURE_FIELDS: + changed = fixture('checkout') + del changed[key] + with self.subTest(key=key), self.assertRaises(ValueError): + outcomes.compare(fixture(), changed, {}) + changed = fixture('checkout') + changed['graphical_session'] = False + with self.assertRaisesRegex(ValueError, 'graphical session'): + outcomes.compare(fixture(), changed, {}) + + def test_extra_packages_require_explicit_reason_without_masking_required_versions(self): + changed = fixture('checkout') + changed['installed_packages'].append('cloud-init.noarch=1') + result = outcomes.compare(fixture(), changed, {}) + self.assertEqual(result['status'], 'failed') + self.assertEqual(result['package_delta']['checkout']['unreviewed'], ['cloud-init.noarch=1']) + self.assertNotIn('package-delta-reviewed', result['checks']) + exceptions = {'checkout': {'cloud-init.noarch': 'Pinned cloud test bootstrap dependency'}} + self.assertEqual(outcomes.compare(fixture(), changed, exceptions)['status'], 'passed') + changed['required_packages']['cloud-init'] = ['cloud-init.noarch=1'] + self.assertEqual(outcomes.compare(fixture(), changed, exceptions)['status'], 'failed') + with self.assertRaisesRegex(ValueError, 'review reason'): + outcomes.compare(fixture(), changed, {'checkout': {'cloud-init.noarch': ''}}) + + def test_normalization_only_unifies_ownership_paths(self): + value = {'wallDir': '/home/qualification/Pictures/Wallpapers', 'runtime': '/usr/share/cybexos/runtime/quickshell', + 'choices': {'passwordless': False}, 'layout': ['nl', 'us']} + normalized = outcomes.normalize(value, '/home/qualification') + self.assertEqual(normalized['wallDir'], '$HOME/Pictures/Wallpapers') + self.assertEqual(normalized['runtime'], '$RUNTIME/quickshell') + self.assertEqual(normalized['choices'], {'passwordless': False}) + self.assertEqual(normalized['layout'], ['nl', 'us']) + + +class SourceIdentity(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='cybexos-source-test-') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) / 'source' + self.root.mkdir() + subprocess.run(['git', 'init', '-q', str(self.root)], check=True) + for name in ('install', 'site.yml', 'inventory/group_vars/all.yml'): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('fixture source\n') + self.provenance = Path(self.temp.name) / 'build.json' + self.provenance.write_text('{}') + self.archive = Path(self.temp.name) / 'source.tar.gz' + + def test_archive_has_identical_content_identity_and_changes_are_detected(self): + initial = source.tree_identity(self.root) + source.write_archive(self.root, self.archive, {'image/build-provenance.json': self.provenance}) + self.assertEqual(source.archive_identity(self.archive), initial) + (self.root / 'install').write_text('changed\n') + self.assertNotEqual(source.tree_identity(self.root), initial) + (self.root / 'install').write_text('fixture source\n') + (self.root / 'install').chmod(0o755) + self.assertNotEqual(source.tree_identity(self.root), initial) + + def test_ignored_files_and_generated_provenance_do_not_shape_content_identity(self): + (self.root / '.gitignore').write_text('local-secret\n') + first = source.tree_identity(self.root) + (self.root / 'local-secret').write_text('never archive') + (self.root / 'image').mkdir() + (self.root / 'image/build-provenance.json').write_text('generated') + self.assertEqual(source.tree_identity(self.root), first) + + def test_archive_rejects_traversal_links_duplicate_and_missing_installer(self): + for names in (['../escape'], ['/absolute'], ['same', 'same'], ['ordinary']): + with self.subTest(names=names): + with tarfile.open(self.archive, 'w:gz') as archive: + for name in names: + member = tarfile.TarInfo(name) + member.size = 1 + archive.addfile(member, io.BytesIO(b'x')) + with self.assertRaises(ValueError): + source.archive_identity(self.archive) + with tarfile.open(self.archive, 'w:gz') as archive: + link = tarfile.TarInfo('link') + link.type, link.linkname = tarfile.SYMTYPE, '/outside' + archive.addfile(link) + with self.assertRaises(ValueError): + source.archive_identity(self.archive) + + +class GuestWorkflow(unittest.TestCase): + def test_public_installer_uses_all_defaults_and_no_fixture_feature_optouts(self): + script = checkout_runner.install_script('plain-nl') + self.assertIn('./install --non-interactive', script) + self.assertNotIn('convergence-vars', script) + self.assertNotIn('--tags', script) + self.assertIn('LANG=nl_NL.UTF-8', script) + self.assertIn('rm -f /etc/sudoers.d/90-cloud-init-users', script) + self.assertIn('export SUDO_USER=qualification', script) + self.assertNotIn('set-x11-keymap', checkout_runner.install_script('plain-nl', repeat=True)) + with self.assertRaises(ValueError): + checkout_runner.install_script('encrypted-us') + + def test_desktop_lifecycle_streams_the_real_managed_service_test(self): + with patch.object(parity.subprocess, 'run') as run: + parity.desktop_lifecycle(Mock(ssh=['ssh', 'fixture'])) + script = run.call_args.kwargs['input'] + self.assertIn('qs_live_begin', script) + self.assertIn('qs_live_end', script) + self.assertIn('sound network accounts keyboard touchpad region', script) + self.assertIn("trap cleanup EXIT", script) + subprocess.run(['bash', '-n'], input=script, text=True, check=True) + + def test_capture_source_is_valid_python_and_requires_explicit_cli(self): + source_text = Path(outcomes.__file__).read_text() + compile(source_text, '', 'exec') + with patch.object(outcomes.os, 'geteuid', return_value=1000), self.assertRaisesRegex(RuntimeError, 'disposable qualification'): + outcomes.capture('qualification', 'iso', 'plain-us', 'fresh', '/never', '/never') + compile(parity.SAVED_CHOICES, '', 'exec') + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_release_gate.py b/image/test_release_gate.py index b194f578..c63b561c 100644 --- a/image/test_release_gate.py +++ b/image/test_release_gate.py @@ -41,6 +41,24 @@ def run(command): (artifacts / 'cybexos-desktop-1.2.3.rpm').write_bytes(b'RPM') if command[0].endswith('/image/publish-pxe'): (pxe / 'iso/candidate.iso').write_bytes(b'candidate') + if command[0].endswith('/image/qualify') and '--capture-outcomes' in command: + destination = Path(command[command.index('--output') + 1]) + destination.mkdir() + scenario = command[command.index('--scenario') + 1] + (destination / 'qualification.json').write_text(json.dumps({ + 'status': 'passed', 'scenario': scenario, 'source_revision': 'a' * 40, + 'source_content_sha256': 'b' * 64, 'checks': ['graphical-installer']})) + if command[0].endswith('/image/qualify-checkout'): + destination = Path(command[command.index('--output') + 1]) + destination.mkdir() + scenario = command[command.index('--scenario') + 1] + (destination / 'checkout-qualification.json').write_text(json.dumps({ + 'status': 'passed', 'scenario': scenario, 'source_revision': 'a' * 40, + 'source_content_sha256': 'b' * 64, 'checks': ['full-checkout-installation', + 'installed-parity-fresh', 'installed-parity-saved', 'full-graphical-session']})) + for profile in ('fresh', 'saved'): + (destination / ('parity-' + profile + '.json')).write_text(json.dumps({ + 'status': 'passed', 'scenario': scenario, 'profile': profile, 'source_content_sha256': 'b' * 64})) return checkout, baseline, output, paths, run, commands def invoke(self, root, same_iso=False): @@ -77,6 +95,13 @@ def test_required_matrix_and_prior_rpm_upgrade_recovery_are_invoked(self): self.assertIn('--candidate-rpm', upgrade) self.assertIn('--recovery-check', upgrade) self.assertIn('--legacy-installer', upgrade) + checkout = [command for command in commands if command[0].endswith('/image/qualify-checkout')] + self.assertEqual(len(checkout), 2) + self.assertEqual([command[command.index('--scenario') + 1] for command in checkout], ['plain-us', 'plain-nl']) + for scenario in ('plain-us', 'plain-nl'): + qualified = json.loads((output / scenario / 'qualification.json').read_text()) + self.assertIn('installed-parity-saved', qualified['checks']) + self.assertEqual(qualified['checkout_parity']['status'], 'passed') self.assertEqual(json.loads((output / 'release-gate.json').read_text())['status'], 'passed') def test_interruption_terminates_owned_process_group_before_returning(self): diff --git a/image/test_session_start.py b/image/test_session_start.py new file mode 100644 index 00000000..11b44ef1 --- /dev/null +++ b/image/test_session_start.py @@ -0,0 +1,12 @@ +"""Run the shared session startup regressions in the image source gate too.""" +import importlib.machinery +import importlib.util +from pathlib import Path + + +source = Path(__file__).resolve().parents[1] / 'tests/session-launcher.py' +loader = importlib.machinery.SourceFileLoader('session_start_fixtures', str(source)) +spec = importlib.util.spec_from_loader(loader.name, loader) +fixtures = importlib.util.module_from_spec(spec) +loader.exec_module(fixtures) +SessionAutostartTests = fixtures.SessionAutostartTests diff --git a/image/test_update_recovery.py b/image/test_update_recovery.py new file mode 100644 index 00000000..72b8bf45 --- /dev/null +++ b/image/test_update_recovery.py @@ -0,0 +1,97 @@ +"""Compare deployed recovery files from real Ansible and RPM assembly logic.""" +import ast +import copy +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +class RecoveryParity(unittest.TestCase): + def test_both_installers_deploy_the_same_recovery_protocol_and_login_barrier(self): + names = { + 'Install transactional Btrfs recovery helper', + 'Install the durable update transaction helpers', + 'Install the interrupted-update boot recovery unit', + 'Create the login recovery dependency directories', + 'Block login if interrupted-update recovery fails', + 'Install Fedora upgrade validation units', + } + tasks = [copy.deepcopy(task) for task in yaml.safe_load( + (ROOT / 'roles/base/tasks/main.yml').read_text()) if task.get('name') in names] + self.assertEqual({task['name'] for task in tasks}, names) + with tempfile.TemporaryDirectory(prefix='cybexos-recovery-parity-') as temporary: + directory = Path(temporary) + checkout, payload = directory / 'checkout', directory / 'rpm' + for relative in ('usr/local/libexec', 'etc/systemd/system'): + (checkout / relative).mkdir(parents=True) + for task in tasks: + task['become'] = False + options = task.get('ansible.builtin.copy') or task['ansible.builtin.file'] + for key in ('owner', 'group'): + options.pop(key, None) + destination = 'dest' if 'dest' in options else 'path' + options[destination] = '{{ fixture_root }}' + options[destination] + if 'src' in options: + options['src'] = str(ROOT / 'roles/base/files') + '/' + options['src'] + playbook = directory / 'recovery.yml' + playbook.write_text(yaml.safe_dump([{ + 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, + 'vars': {'fixture_root': str(checkout)}, 'tasks': tasks, + }])) + result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + # Execute the real contiguous recovery assembly section; unrelated + # application downloads and privileged image construction stay out + # of this disposable-filesystem contract test. + main = next(node for node in ast.parse((ROOT / 'image/package').read_text()).body + if isinstance(node, ast.FunctionDef) and node.name == 'main') + start = next(index for index, node in enumerate(main.body) + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) and node.value.func.id == 'copy' + and node.value.args and isinstance(node.value.args[0], ast.Constant) + and node.value.args[0].value == 'roles/base/files/cybexos-system-snapshot') + end = next(index for index, node in enumerate(main.body) + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) + and node.value.value == 'roles/base/files/recovery') + + def write(relative, content, executable=False): + target = payload / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + target.chmod(0o755 if executable else 0o644) + + def package_copy(source, relative, executable=False): + write(relative, (ROOT / source).read_text(), executable) + + exec(compile(ast.Module(body=main.body[start:end], type_ignores=[]), + str(ROOT / 'image/package'), 'exec'), + {'ROOT': ROOT, 'payload': payload, 'write': write, 'copy': package_copy}) + source_files = sorted(path for path in checkout.rglob('*') if path.is_file()) + self.assertGreaterEqual(len(source_files), 13) + for source in source_files: + relative = source.relative_to(checkout).as_posix() + installed = relative.replace('usr/local/libexec/', 'usr/libexec/').replace( + 'etc/systemd/system/', 'usr/lib/systemd/system/') + expected = source.read_text() + if relative.startswith('etc/systemd/system/'): + expected = expected.replace('/usr/local/libexec/cybexos-', '/usr/libexec/cybexos-') + self.assertEqual((payload / installed).read_text(), expected, relative) + self.assertEqual(os.access(source, os.X_OK), os.access(payload / installed, os.X_OK)) + # Every shipped recovery artifact must be covered by the RPM file + # manifest, including the new user-sessions dependency directory. + spec = (ROOT / 'image/cybexos-desktop.spec').read_text() + self.assertIn('/usr/libexec/cybexos-*', spec) + self.assertIn('/usr/lib/systemd/system/sddm.service.d/', spec) + self.assertIn('/usr/lib/systemd/system/systemd-user-sessions.service.d/60-cybexos-update-recover.conf', spec) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_user_parity.py b/image/test_user_parity.py index d574acd0..21540cb3 100644 --- a/image/test_user_parity.py +++ b/image/test_user_parity.py @@ -1,4 +1,6 @@ """Regressions for per-user workstation policy that ISO installations lacked.""" +import ast +import shutil import configparser import importlib.machinery import importlib.util @@ -58,6 +60,45 @@ def unit(text): class ProvisioningContract(unittest.TestCase): + def test_smb_default_matches_both_paths_and_preserves_user_choice(self): + relative = 'roles/desktop/tasks/main.yml' + install = task(relative, 'Default Files SMB connections to WORKGROUP')['ansible.builtin.copy'] + source = ROOT / 'roles/desktop/files' / install['src'] + destination = install['dest'] + # Resolve the actual RPM copy mapping rather than assuming a shared file. + copies = [node for node in ast.walk(ast.parse((ROOT / 'image/package').read_text())) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == 'copy' and len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and node.args[1].value == destination.lstrip('/')] + self.assertEqual(len(copies), 1) + packaged_source = ROOT / ast.literal_eval(copies[0].args[0]) + self.assertEqual(packaged_source, source) + self.assertIn(destination, (ROOT / 'image/cybexos-desktop.spec').read_text().splitlines()) + compile_task = task(relative, 'Compile GSettings schemas after changing the SMB default') + self.assertEqual(compile_task['when'], 'desktop_smb_schema_override is changed') + uninstall = (ROOT / 'roles/uninstall/tasks/main.yml').read_text() + self.assertIn(' - ' + destination, uninstall) + self.assertIn("selectattr('item', 'equalto', '" + destination + "')", uninstall) + for override in (source, packaged_source): + with tempfile.TemporaryDirectory(prefix='cybexos-smb-') as temporary: + schemas = Path(temporary) + shutil.copy('/usr/share/glib-2.0/schemas/org.gnome.system.smb.gschema.xml', schemas) + shutil.copy('/usr/share/glib-2.0/schemas/org.gnome.system.gvfs.enums.xml', schemas) + shutil.copy(override, schemas) + subprocess.run(['glib-compile-schemas', '--strict', str(schemas)], check=True) + result = subprocess.run(['/usr/bin/python3', '-c', + "from gi.repository import Gio; " + "s = Gio.Settings.new('org.gnome.system.smb'); " + "assert s.get_string('workgroup') == 'WORKGROUP'; " + "assert s.set_string('workgroup', 'OFFICE'); " + "assert Gio.Settings.new('org.gnome.system.smb').get_string('workgroup') == 'OFFICE'; " + "s.reset('workgroup'); " + "assert s.get_string('workgroup') == 'WORKGROUP'"], + env={**os.environ, 'GSETTINGS_SCHEMA_DIR': str(schemas), + 'GSETTINGS_BACKEND': 'memory'}, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_offline_gtk_task_skips_private_bus_creation(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -218,14 +259,15 @@ def test_seed_uses_the_managed_kitty_fragment_and_include(self): prepare_session(ROOT, self.payload, INVENTORY) kitty = self.vendor / 'user-seed/.config/kitty' self.assertEqual((kitty / 'cybexos.conf').read_bytes(), (ROOT / 'roles/dotfiles/files/kitty.conf').read_bytes()) - # Exactly what blockinfile writes for the workstation task, so repair - # recognizes a seeded kitty.conf instead of appending a second include. + # The shared include editor produces the packaged first-run block. + import importlib.util + spec = importlib.util.spec_from_file_location('include_policy', ROOT / 'image/library/cybexos_user_include.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) include = task('roles/dotfiles/tasks/personal.yml', 'Include the managed Kitty fragment without replacing user configuration') - options = include['ansible.builtin.blockinfile'] - marker = options['marker'] - self.assertEqual(KITTY_INCLUDE, '\n'.join((marker.replace('{mark}', 'BEGIN'), options['block'], - marker.replace('{mark}', 'END'))) + '\n') + self.assertEqual(include['cybexos_user_include']['kind'], 'kitty') + self.assertEqual(KITTY_INCLUDE, module.render('', 'kitty')[0]) self.assertEqual((kitty / 'kitty.conf').read_text(), KITTY_INCLUDE) # The theme integration expects the generated palette to be included # from the fragment, after its fallback colours. diff --git a/image/vm_testing.py b/image/vm_testing.py index 189d5530..c4d642fb 100644 --- a/image/vm_testing.py +++ b/image/vm_testing.py @@ -139,11 +139,13 @@ def stop_with_harness(): class TestVM: """Own exactly one disposable virtual disk; never attach host block devices.""" - def __init__(self, work, firmware="uefi", memory=16384, guard_disk=False): + def __init__(self, work, firmware="uefi", memory=16384, guard_disk=False, network_restricted=True): self.work = validate_qemu_path(Path(work).resolve()) self.firmware = firmware self.memory = memory self.guard_disk = guard_disk + self.network_restricted = network_restricted + self.seed = None self.owned = False self.process = None self.console = None @@ -246,7 +248,7 @@ def _start(self, iso, user): "-drive", f"file={self.disk},format=qcow2,if=none,id=qualification-disk,werror=report,rerror=report", "-device", f"virtio-blk-pci,drive=qualification-disk,serial={QUALIFICATION_DISK_SERIAL}", *firmware, "-device", "virtio-vga", "-device", "qemu-xhci", "-device", "usb-tablet", - "-netdev", f"user,id=net,restrict=on,hostfwd=tcp:127.0.0.1:{self.port}-:22", "-device", "virtio-net-pci,netdev=net", + "-netdev", f"user,id=net,{'restrict=on,' if self.network_restricted else ''}hostfwd=tcp:127.0.0.1:{self.port}-:22", "-device", "virtio-net-pci,netdev=net", "-vnc", f"127.0.0.1:{self.vnc_port - 5900}", "-serial", f"file:{self.work / 'serial.log'}", "-qmp", f"unix:{self.qmp_path},server=on,wait=off", "-monitor", "none"] if self.guard_disk: @@ -254,6 +256,10 @@ def _start(self, iso, user): "-device", f"virtio-blk-pci,drive=unused-disk,serial={QUALIFICATION_UNUSED_SERIAL}"] if iso is not None: args += ["-cdrom", str(require_test_iso(iso)), "-boot", "d"] + if self.seed is not None: + if self.seed.parent != self.work or self.seed.is_symlink() or not self.seed.is_file(): + raise ValueError('Cloud seed must be a task-owned regular file') + args += ["-drive", f"file={self.seed},format=raw,if=virtio,readonly=on"] self.console = (self.work / "qemu.log").open("a") self.process = subprocess.Popen(args, stdout=self.console, stderr=subprocess.STDOUT, process_group=0, preexec_fn=stop_with_harness) diff --git a/roles/base/files/cybexos-major-upgrade b/roles/base/files/cybexos-major-upgrade new file mode 100755 index 00000000..db42dd18 --- /dev/null +++ b/roles/base/files/cybexos-major-upgrade @@ -0,0 +1,717 @@ +#!/usr/bin/python3 +"""Prepare a qualified Fedora upgrade, reboot explicitly, and validate or roll back. + +Target support comes from an explicitly selected compatible source release or +signed desktop RPM, never from incrementing this machine's Fedora version. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from datetime import datetime, timezone +import configparser +import fcntl +import grp +import hashlib +import json +import os +from pathlib import Path +import platform +import pwd +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import uuid + +import yaml + +STATE = Path('/var/lib/cybexos/major-upgrade') +PAYLOADS = Path('/var/lib/cybexos/major-upgrade-payloads') +CONFIG = Path('/etc/cybexos/config.yml') +OFFLINE = Path('/usr/lib/sysimage/libdnf5/offline') +TRIGGER = Path('/system-update') +LIBEXEC = Path(__file__).resolve().parent +TRANSACTION = LIBEXEC / 'cybexos-update-transaction' +SNAPSHOT = LIBEXEC / 'cybexos-system-snapshot' +TERMINAL = {'committed', 'cancelled', 'failed', 'rolled-back'} + + +class Failure(Exception): + pass + + +class Busy(Failure): + pass + + +def run(command, timeout=120, env=None): + environment = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': '/root', + 'LANG': 'C.UTF-8', 'LC_ALL': 'C', 'PYTHONDONTWRITEBYTECODE': '1'} + if env: + environment.update(env) + process = subprocess.Popen([str(value) for value in command], env=environment, + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + try: + output, error = process.communicate(timeout=timeout) + except BaseException: + # Rollback must not race a grandchild still applying packages/config. + # Every command owns a separate group; never signal unrelated writers. + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.communicate(timeout=10) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.communicate() + # A descendant may close inherited pipes and outlive an exited parent. + # Reaping the parent alone is not proof that its process group is gone. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + raise + if process.returncode: + raise Failure(f'{command[0]} failed: {(error or output).strip()[-1600:]}') + return output.strip() + + +def write(value): + STATE.mkdir(mode=0o700, parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix='.status-', dir=STATE) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream, sort_keys=True, indent=2) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, STATE / 'status.json') + descriptor = os.open(STATE, os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + Path(name).unlink(missing_ok=True) + + +def status(): + try: + value = json.loads((STATE / 'status.json').read_text()) + except FileNotFoundError: + return {'v': 1, 'state': 'idle'} + if not isinstance(value, dict) or value.get('v') != 1: + raise Failure('Upgrade status is damaged; inspect the transaction recovery journal') + return value + + +def transaction(action, identifier, *extra): + return json.loads(run([TRANSACTION, action, identifier, *extra], timeout=900)) + + +def boot_id(): + return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + + +def fedora_release(): + values = dict(line.split('=', 1) for line in Path('/etc/os-release').read_text().splitlines() if '=' in line) + if values.get('ID', '').strip('"') != 'fedora': + raise Failure('Major upgrades require Fedora') + release = values.get('VERSION_ID', '').strip('"') + if not release.isdecimal(): + raise Failure('Cannot identify the installed Fedora release') + return release + + +def digest(path): + result = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + result.update(chunk) + return result.hexdigest() + + +def tree_digest(path): + result = hashlib.sha256() + if (path / '.git').exists(): + names = run(['git', '-c', 'safe.directory=' + str(path), '-C', str(path), 'ls-files', '-z']) + items = [path / name for name in names.split('\0') if name] + else: + items = path.rglob('*') + for item in sorted(items): + relative = item.relative_to(path) + if '.git' in relative.parts or '__pycache__' in relative.parts or item.suffix == '.pyc': + continue + if item.is_symlink(): + link = os.readlink(item) + # Executable source cannot escape the frozen reviewed tree. + if not item.resolve().is_relative_to(path.resolve()): + raise Failure(f'Source symlink escapes its tree: {relative}') + value = 'link:' + link + elif item.is_file(): + value = digest(item) + ':' + str(bool(item.stat().st_mode & 0o111)) + elif item.is_dir(): + continue + else: + raise Failure(f'Unsupported source file: {relative}') + result.update((str(relative) + '\0' + value + '\0').encode()) + return result.hexdigest() + + +def compatible(manifest, target, architecture, schema): + if (not isinstance(manifest, dict) or manifest.get('product') != 'cybexos' + or target not in manifest.get('supportedFedora', []) + or architecture not in manifest.get('architectures', []) + or manifest.get('configSchema') != schema): + raise Failure(f'The selected release does not support Fedora {target}, {architecture}, and this saved configuration schema') + + +def inspect_target(target, source=None, rpm=None): + saved = yaml.safe_load(CONFIG.read_text()) + if not isinstance(saved, dict): + raise Failure('A saved CybexOS installation configuration is required') + architecture = platform.machine() + if source: + source = Path(source).resolve(strict=True) + manifest = json.loads((source / 'release-manifest.json').read_text()) + compatible(manifest, target, architecture, saved.get('config_schema_version')) + inventory = yaml.safe_load((source / 'inventory/group_vars/all.yml').read_text()) + if str(inventory.get('fedora_release')) != target: + raise Failure('Source inventory and target release manifest disagree') + for name in ('site.yml', 'ansible.cfg', 'inventory/hosts.yml', 'verify'): + if not (source / name).is_file(): + raise Failure(f'The selected source release is incomplete: {name}') + revision = '' + if (source / '.git').exists(): + # The administrator explicitly selects this source. Its clean + # commit is frozen, not pulled or switched during an upgrade. + prefix = ['git', '-c', 'safe.directory=' + str(source), '-C', str(source)] + if run([*prefix, 'status', '--porcelain', '--untracked-files=normal']): + raise Failure('The target checkout has local changes; select a clean reviewed release') + revision = run([*prefix, 'rev-parse', 'HEAD']) + return {'kind': 'source', 'path': str(source), 'digest': tree_digest(source), + 'revision': revision, 'manifest': manifest} + if rpm: + rpm = Path(rpm).resolve(strict=True) + signature = run(['rpmkeys', '--checksig', str(rpm)]) + if 'signatures OK' not in signature or any(word in signature for word in ('NOKEY', 'NOT OK', 'NOTTRUSTED')): + raise Failure('The target desktop RPM must have a signature verified by the installed trusted keyring') + identity = run(['rpm', '-qp', '--qf', '%{NAME}\n%{RELEASE}\n%{ARCH}\n%{VERSION}', str(rpm)]).splitlines() + if (len(identity) != 4 or identity[0] != 'cybexos-desktop' + or not re.search(r'\.fc' + re.escape(target) + r'(?:\.|$)', identity[1]) + or identity[2] != architecture): + raise Failure('The signed RPM is not a desktop release for the target Fedora and architecture') + # An explicit signed capability avoids equating a dist tag with a + # qualification claim. Older packages without it cannot opt in. + provides = run(['rpm', '-qp', '--provides', str(rpm)]).splitlines() + if f'cybexos-supported-fedora = {target}' not in provides: + raise Failure('The signed desktop RPM does not declare support for the target Fedora release') + return {'kind': 'rpm', 'path': str(rpm), 'digest': digest(rpm), 'identity': identity} + raise Failure('Select a compatible reviewed --source directory or signed --rpm; no next release is assumed supported') + + +def verify_backup(manifest_path, account): + if not manifest_path: + raise Failure('Provide --backup with a recent off-disk backup manifest; a root snapshot does not back up personal data') + path = Path(manifest_path).resolve(strict=True) + manifest = json.loads(path.read_text()) + if manifest.get('v') != 1 or not isinstance(manifest.get('archives'), list): + raise Failure('Backup manifest needs v=1, createdAt and an archives list') + created = datetime.fromisoformat(manifest.get('createdAt', '').replace('Z', '+00:00')) + if created.tzinfo is None or not 0 <= time.time() - created.timestamp() <= 7 * 86400: + raise Failure('Verify a backup made within the last seven days') + root_device = run(['findmnt', '-n', '-o', 'UUID', '-T', '/']) + backup_device = run(['findmnt', '-n', '-o', 'UUID', '-T', str(path)]) + if not root_device or not backup_device or root_device == backup_device: + raise Failure('The backup must be on a different filesystem with a verifiable UUID') + required = {str(Path(account.pw_dir)), '/etc'} + for name in ('/var/lib/xps-hardware', '/etc/pki/akmods'): + if Path(name).exists(): + required.add(name) + covered = set() + receipts = [] + for archive in manifest['archives']: + relative = Path(archive.get('path', '')) + if relative.is_absolute() or '..' in relative.parts or not relative.parts: + raise Failure('Backup archive paths must stay beside their manifest') + file = path.parent / relative + if file.is_symlink() or not file.is_file() or digest(file) != archive.get('sha256'): + raise Failure(f'Backup checksum verification failed: {relative}') + # Read the entire archive through tar's parser; no archive member is + # extracted or trusted as executable code. + listing = run(['tar', '-tf', str(file)], timeout=3600).splitlines() + members = {('/' + entry.removeprefix('./').lstrip('/')).rstrip('/') for entry in listing} + for scope in archive.get('covers', []): + if scope in members and any(member.startswith(scope.rstrip('/') + '/') for member in members): + covered.add(scope) + receipts.append({'path': str(file), 'sha256': archive['sha256']}) + if required - covered: + raise Failure('Backup archives do not cover: ' + ', '.join(sorted(required - covered))) + return {'manifest': str(path), 'sha256': digest(path), 'archives': receipts, + 'verifiedAt': datetime.now(timezone.utc).isoformat()} + + +def offline_digest(): + if not OFFLINE.is_dir(): + return '' + entries = [p for p in sorted(OFFLINE.rglob('*')) if p.is_file() and p.suffix != '.rpm'] + if not entries: + return '' + total = hashlib.sha256() + for item in entries: + if item.is_symlink() or item.stat().st_size > 64 * 1024 * 1024: + raise Failure('Unexpected offline transaction metadata') + total.update((str(item.relative_to(OFFLINE)) + '\0' + digest(item)).encode()) + return total.hexdigest() + + +def check_repositories(current, kind): + repos = [] + for file in sorted(Path('/etc/yum.repos.d').glob('*.repo')): + parser = configparser.ConfigParser(interpolation=None) + parser.read(file) + for name in parser.sections(): + section = parser[name] + if not section.getboolean('enabled', fallback=True): + continue + if kind == 'rpm' and name == 'cybexos-desktop': + continue # The explicitly signed staged RPM replaces it after boot. + if not section.getboolean('gpgcheck', fallback=True): + raise Failure(f'Repository {name} disables signature verification') + urls = ' '.join(section.get(key, '') for key in ('baseurl', 'metalink', 'mirrorlist')) + if re.search(r'(?:/|fc)' + re.escape(current) + r'(?:/|\b)', urls): + raise Failure(f'Repository {name} pins Fedora {current}; provide a reviewed target-compatible repository before preparing') + repos.append(name) + if not repos: + raise Failure('No verified target-capable Fedora repositories are enabled') + return repos + + +def preflight(target, candidate, backup, uid): + current = fedora_release() + if not target.isdecimal() or not 1 <= int(target) - int(current) <= 2: + raise Failure('Select a newer Fedora release, at most two releases ahead') + account = pwd.getpwuid(uid) + if uid < 1000 or uid >= 65534: + raise Failure('Select the installed desktop account with --uid') + if TRIGGER.exists() or TRIGGER.is_symlink() or offline_digest(): + raise Failure('Another offline transaction exists; finish or cancel it before preparing a major upgrade') + for path, minimum in (('/', 15 * 1024**3), ('/var', 15 * 1024**3), ('/boot', 512 * 1024**2)): + if shutil.disk_usage(path).free < minimum: + raise Failure(f'{path} needs at least {minimum // 1024**2} MiB free for the upgrade and recovery') + latest = run(['rpm', '-q', '--qf', '%{VERSION}-%{RELEASE}.%{ARCH}\n', 'kernel-core']).splitlines() + if platform.release() not in latest: + raise Failure('Boot an installed Fedora kernel before upgrading') + # `grubby --default-kernel` names the latest/default boot entry without + # making lexical version ordering assumptions. + if Path(run(['grubby', '--default-kernel'])).name != 'vmlinuz-' + platform.release(): + raise Failure('Reboot into the default updated kernel before upgrading') + for marker in ('/var/lib/xps-hardware/ipu7/reboot-required', '/var/run/reboot-required'): + if Path(marker).exists(): + raise Failure(f'A pending hardware/system reboot must be resolved first: {marker}') + recovery = json.loads(run([SNAPSHOT, 'list', '--json'])) + if not recovery.get('supported') or recovery.get('recoveryBoot') or recovery.get('pendingReboot'): + raise Failure('Major upgrades require the managed Btrfs layout booted into its normal root') + hardware = [] + for name in ('xps-ipu7-abi-check', 'xps-ipu7-userspace-check'): + helper = Path('/usr/local/libexec') / name + if helper.exists(): + run([helper, platform.release()] if name.endswith('abi-check') else [helper], timeout=120) + hardware.append(str(helper)) + run(['rpm', '--verifydb']) + run(['dnf5', 'check'], timeout=600) + try: + run(['dnf5', '--refresh', 'check-upgrade'], timeout=600) + except Failure as error: + raise Failure('Finish current-release updates and resolve repository errors before upgrading: ' + str(error)) from None + run(['dnf5', 'system-upgrade', 'download', '--help']) + secure_boot = run(['mokutil', '--sb-state']) if Path('/sys/firmware/efi').exists() else 'legacy boot' + # Existing camera signing health is a baseline, not a promise that the + # next kernel works. A qualified target is still required. + if 'SecureBoot enabled' in secure_boot and Path('/etc/pki/akmods/certs/public_key.der').exists(): + run(['mokutil', '--test-key', '/etc/pki/akmods/certs/public_key.der']) + return {'currentFedora': current, 'targetFedora': target, 'uid': uid, + 'home': account.pw_dir, 'kernel': platform.release(), 'secureBoot': secure_boot, + 'hardwareChecks': hardware, + 'repositories': check_repositories(current, candidate['kind']), + 'backup': verify_backup(backup, account)} + + +@contextmanager +def operation_lock(uid=None, wait=False): + STATE.mkdir(mode=0o700, parents=True, exist_ok=True) + with (STATE / 'lock').open('a') as handle: + try: + fcntl.flock(handle, fcntl.LOCK_EX | (0 if wait else fcntl.LOCK_NB)) + except BlockingIOError: + raise Busy('Another major-upgrade operation is running; do not interrupt its DNF download') from None + # Cooperate with install/uninstall/the ordinary updater for this user. + shared = None + if uid is not None: + runtime = Path(f'/run/user/{uid}') + if runtime.is_dir(): + lock_path = runtime / 'update.lock' + try: + descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + os.fchown(descriptor, uid, pwd.getpwuid(uid).pw_gid) + except FileExistsError: + descriptor = os.open(lock_path, os.O_RDWR | os.O_NOFOLLOW) + shared = os.fdopen(descriptor, 'a') + try: + fcntl.flock(shared, fcntl.LOCK_EX | (0 if wait else fcntl.LOCK_NB)) + except BlockingIOError: + shared.close() + raise Busy('Another CybexOS install/update operation is running') from None + try: + yield + finally: + if shared: + shared.close() + + +def prepare(args): + previous = status() + if previous['state'] not in TERMINAL | {'idle'}: + raise Failure('An existing major upgrade needs completion or cancellation first') + candidate = inspect_target(args.target, args.source, args.rpm) + baseline = preflight(args.target, candidate, args.backup, args.uid) + identifier = 'major-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S') + '-' + uuid.uuid4().hex[:8] + PAYLOADS.mkdir(mode=0o755, parents=True, exist_ok=True) + os.chmod(PAYLOADS, 0o755) + staging = PAYLOADS / identifier + staging.mkdir(mode=0o755) + os.chmod(staging, 0o755) + record = {'v': 1, 'id': identifier, 'state': 'preparing', 'bootId': boot_id(), + 'candidate': candidate, **baseline, 'rebootRequested': False} + write(record) + begun = False + try: + source = Path(candidate['path']) + if candidate['kind'] == 'source': + staged = staging / 'source' + if (source / '.git').exists(): + staged.mkdir(mode=0o755) + run(['git', '-c', 'safe.directory=' + str(source), '-C', str(source), + 'checkout-index', '--all', '--prefix=' + str(staged) + '/']) + else: + shutil.copytree(source, staged, symlinks=True, + ignore=shutil.ignore_patterns('__pycache__', '*.pyc')) + for item in [staged, *staged.rglob('*')]: + if not item.is_symlink(): + item.chmod(0o755 if item.is_dir() or item.stat().st_mode & 0o111 else 0o644) + if tree_digest(staged) != candidate['digest']: + raise Failure('The source changed while it was staged') + else: + staged = staging / 'desktop.rpm' + shutil.copyfile(source, staged) + if digest(staged) != candidate['digest']: + raise Failure('The RPM changed while it was staged') + inspect_target(args.target, rpm=staged) + candidate['path'] = str(staged) + write(record) + point = run([SNAPSHOT, 'create', f'Before Fedora {args.target} major upgrade'], timeout=900).splitlines()[-1] + transaction('begin', identifier, point, '--uid', str(args.uid)) + begun = True + record['downloadUnit'] = 'cybexos-major-download-' + identifier + record['state'] = 'downloading' + write(record) + run(['systemd-run', '--unit=' + record['downloadUnit'], '--collect', + '--property=Type=exec', '--property=RuntimeMaxSec=3h', + '--property=KillMode=control-group', '--property=UMask=0077', + str(Path(__file__).resolve()), 'download', identifier]) + return record + except BaseException as error: + record.update(state='failed', error=str(error)) + write(record) + if begun: + transaction('abort', identifier) + shutil.rmtree(staging) + raise + + +def download(identifier): + record = status() + if record.get('id') != identifier or record['state'] != 'downloading': + raise Failure('Download request no longer matches the prepared major upgrade') + owns_download = False + try: + if offline_digest() or TRIGGER.exists() or TRIGGER.is_symlink(): + raise Failure('Another offline transaction appeared before the download began') + owns_download = True + command = ['dnf5', '--assumeyes', '--refresh', '--releasever=' + record['targetFedora'], + '--setopt=*.skip_if_unavailable=false', '--setopt=gpgcheck=true'] + if record['candidate']['kind'] == 'rpm': + command += ['--disable-repo=cybexos-desktop'] + run([*command, 'system-upgrade', 'download'], timeout=7200) + record['offlineDigest'] = offline_digest() + if not record['offlineDigest']: + raise Failure('DNF did not publish an offline transaction') + record['state'] = 'ready' + transaction('arm-upgrade', identifier, '--metadata', json.dumps(record)) + write(record) + return record + except BaseException as error: + # Retain failed-download diagnostics and payload until an explicit + # cancel. Never cancel a still-running DNF or an unknown transaction. + record.update(state='download-failed' if owns_download else 'failed', error=str(error), + offlineDigest=offline_digest() if owns_download else '') + write(record) + if not owns_download: + transaction('abort', identifier) + shutil.rmtree(PAYLOADS / identifier) + raise + + +def ensure_ours(record): + if record.get('state') not in {'ready', 'download-failed'} or record.get('rebootRequested'): + raise Failure('The offline upgrade is already scheduled or has started; cancellation is no longer safe') + if TRIGGER.exists() or TRIGGER.is_symlink(): + raise Failure('An offline reboot is already scheduled; do not cancel or interrupt it') + if record.get('bootId') != boot_id() and fedora_release() != record.get('currentFedora'): + raise Failure('The installed Fedora release changed since preparation; inspect recovery before proceeding') + if offline_digest() != record.get('offlineDigest', ''): + raise Failure('The DNF offline transaction changed; refusing to cancel or reboot an unrecognized transaction') + + +def cancel(): + record = status() + ensure_ours(record) + journal = transaction('status', record['id']) + if journal['state'] not in {'prepared', 'awaiting-upgrade', 'aborted'}: + raise Failure('The upgrade checkpoint has begun applying; inspect recovery before cancelling') + run(['dnf5', 'offline', 'clean']) + # Boot recovery can already abort a prepared checkpoint after a failed + # download. Its unchanged cached metadata can still be explicitly cleaned. + if journal['state'] != 'aborted': + transaction('abort', record['id']) + record['state'] = 'cancelled' + write(record) + shutil.rmtree(PAYLOADS / record['id']) + return record + + +def reboot(): + record = status() + if record['state'] == 'rolled-back': + run(['systemctl', '--no-block', 'reboot']) + return record + ensure_ours(record) + if record['state'] != 'ready' or not record.get('offlineDigest'): + raise Failure('The complete offline download must be ready before rebooting') + # A normal reboot before scheduling the offline upgrade is harmless. The + # next boot boundary must refer to the explicit offline reboot, otherwise + # the timer could start convergence while this old-release boot is active. + record['bootId'] = boot_id() + record['rebootRequested'] = True + write(record) + try: + run(['dnf5', 'offline', 'reboot']) + except BaseException: + if not TRIGGER.exists() and not TRIGGER.is_symlink(): + record['rebootRequested'] = False + write(record) + raise + return record + + +def converge(record): + candidate = record['candidate'] + path = Path(candidate['path']) + actual = tree_digest(path) if candidate['kind'] == 'source' else digest(path) + if actual != candidate['digest']: + raise Failure('The staged upgrade payload changed after preparation') + account = pwd.getpwuid(record['uid']) + if candidate['kind'] == 'source': + env = {'ANSIBLE_CONFIG': str(path / 'ansible.cfg'), + 'ANSIBLE_ROLES_PATH': str(path / 'roles'), + 'ANSIBLE_FORCE_HANDLERS': 'true'} + values = {'primary_user': account.pw_name, 'primary_home': account.pw_dir, + 'primary_group': grp.getgrgid(account.pw_gid).gr_name, + 'config_repo': str(path)} + run(['ansible-playbook', '-i', path / 'inventory/hosts.yml', path / 'site.yml', + '--extra-vars', '@' + str(CONFIG), '--extra-vars', json.dumps(values)], timeout=3600, env=env) + # Keep the qualified source as the active release; do not leave managed + # scripts referring to an input checkout that the user may delete. + record['retainedSource'] = str(path) + else: + inspect_target(record['targetFedora'], rpm=path) + run(['dnf5', '--assumeyes', '--setopt=gpgcheck=true', '--setopt=localpkg_gpgcheck=true', + 'install', path], timeout=1800) + run(['/usr/libexec/cybexos-reconcile', '--retry'], timeout=1200) + reconciled = json.loads(run(['/usr/libexec/cybexos-reconcile', '--status'])) + accounts = reconciled.get('accounts') + desired = reconciled.get('desiredVersion') + if (reconciled.get('pending') is not False or reconciled.get('state') != 'ready' + or not desired or reconciled.get('version') != desired + or not isinstance(accounts, dict) or not accounts + or any(not isinstance(entry, dict) or entry.get('state') != 'ready' + or entry.get('version') != desired for entry in accounts.values()) + or accounts.get(account.pw_name, {}).get('uid') != account.pw_uid): + raise Failure('The target desktop package has not finished account reconciliation') + + +def finish_committed(record): + """Retry bookkeeping without crossing a durable transaction commit.""" + record['state'] = 'committed' + packaged = record['candidate']['kind'] == 'rpm' + if packaged: + record['cleanupPending'] = True + write(record) + if packaged: + try: + shutil.rmtree(PAYLOADS / record['id']) + except FileNotFoundError: + pass + record.pop('cleanupPending', None) + write(record) + return record + + +def finalize(identifier): + record = status() + if record.get('id') != identifier: + raise Failure('The boot recovery transaction does not match the staged major upgrade') + journal = transaction('status', identifier) + if journal['state'] in {'committed', 'aborted', 'rolled-back'}: + if journal['state'] == 'committed': + return finish_committed(record), 0 + record['state'] = {'aborted': 'cancelled'}.get(journal['state'], journal['state']) + write(record) + return record, 0 + if journal['state'] == 'awaiting-upgrade': + if not record.get('rebootRequested') or record['bootId'] == boot_id(): + return record, 0 + try: + if fedora_release() != record['targetFedora']: + raise Failure('The offline upgrade did not boot the requested Fedora release') + transaction('applying', identifier) + record['state'] = 'converging' + write(record) + converge(record) + if not re.search(r'\.fc' + re.escape(record['targetFedora']) + r'(?:\.|$)', platform.release()): + raise Failure('The running kernel is not from the target Fedora release') + if 'SecureBoot enabled' in record.get('secureBoot', ''): + if 'SecureBoot enabled' not in run(['mokutil', '--sb-state']): + raise Failure('Secure Boot changed during the upgrade') + for helper in record.get('hardwareChecks', []): + run([helper, platform.release()] if helper.endswith('abi-check') else [helper], timeout=120) + transaction('await-desktop', identifier) + record['state'] = 'awaiting-desktop' + write(record) + except BaseException as error: + transaction('rollback', identifier) + record.update(state='rolled-back', error=str(error), restartRequired=True) + write(record) + return record, 75 + elif journal['state'] in {'awaiting-desktop', 'validating'}: + account = pwd.getpwuid(record['uid']) + bus = Path(f'/run/user/{account.pw_uid}/bus') + if not bus.exists(): + return record, 0 + session = ['runuser', '-u', account.pw_name, '--', 'env', + 'XDG_RUNTIME_DIR=' + str(bus.parent), + 'DBUS_SESSION_BUS_ADDRESS=unix:path=' + str(bus), 'systemctl', '--user'] + try: + run([*session, 'is-active', 'hyprland-session.target']) + except Failure: + return record, 0 + try: + # Once the real session started, failure is actionable; do not + # wait forever for a failed shell or report the recovery bar as healthy. + transaction('commit', identifier) + except BaseException as error: + # The command can lose its output after durably committing. Check + # that journal first; if it cannot be read, leave recovery pending + # rather than guessing that a committed update may be reverted. + observed = transaction('status', identifier) + if observed['state'] != 'committed': + transaction('rollback', identifier) + record.update(state='rolled-back', error=str(error), restartRequired=True) + write(record) + return record, 75 + record['validatedBoot'] = boot_id() + # Failure to save status or remove an RPM payload is bookkeeping work, + # not permission to roll back a successfully committed transaction. + return finish_committed(record), 0 + return record, 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + for name in ('check', 'prepare'): + command = commands.add_parser(name) + command.add_argument('--target', required=True) + target = command.add_mutually_exclusive_group(required=True) + target.add_argument('--source', type=Path) + target.add_argument('--rpm', type=Path) + command.add_argument('--backup', type=Path) + command.add_argument('--uid', type=int, default=int(os.environ.get('SUDO_UID', '0'))) + for name in ('status', 'reboot', 'cancel', 'finalize-current'): + commands.add_parser(name) + commands.add_parser('finalize').add_argument('id') + commands.add_parser('download').add_argument('id') + args = parser.parse_args(argv) + if os.geteuid() != 0: + parser.error('Run through sudo with the installed desktop account in SUDO_UID or --uid') + os.umask(0o077) + def interrupted(_signum, _frame): + raise InterruptedError('Upgrade helper interrupted; child processes were stopped before recovery') + signal.signal(signal.SIGTERM, interrupted) + try: + if args.command == 'status': + print(json.dumps(status(), sort_keys=True)) + return 0 + if args.command == 'finalize-current': + current = status() + if current['state'] in TERMINAL | {'idle', 'preparing', 'downloading', 'download-failed'}: + if current['state'] != 'committed' or not current.get('cleanupPending'): + return 0 + if current['state'] == 'ready' and (not current.get('rebootRequested') or current['bootId'] == boot_id()): + return 0 + owner = getattr(args, 'uid', None) + if args.command == 'download': + owner = status().get('uid') + with operation_lock(owner, wait=args.command == 'download'): + if args.command == 'check': + candidate = inspect_target(args.target, args.source, args.rpm) + result = {'supported': True, 'candidate': candidate, + **preflight(args.target, candidate, args.backup, args.uid)} + elif args.command == 'prepare': + result = prepare(args) + elif args.command == 'download': + result = download(args.id) + elif args.command == 'cancel': + result = cancel() + elif args.command == 'reboot': + result = reboot() + else: + identifier = args.id if args.command == 'finalize' else status().get('id') + if not identifier: + return 0 + result, code = finalize(identifier) + print(json.dumps(result, sort_keys=True)) + return code + print(json.dumps(result, sort_keys=True)) + except Busy as error: + if args.command == 'finalize-current': + return 0 + print('cybexos-major-upgrade: ' + str(error), file=sys.stderr) + return 1 + except (Failure, OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print('cybexos-major-upgrade: ' + str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/roles/base/files/cybexos-major-upgrade-validate.service b/roles/base/files/cybexos-major-upgrade-validate.service new file mode 100644 index 00000000..ae5a26da --- /dev/null +++ b/roles/base/files/cybexos-major-upgrade-validate.service @@ -0,0 +1,14 @@ +[Unit] +Description=Validate the desktop after a CybexOS Fedora major upgrade +After=systemd-user-sessions.service +ConditionPathExists=!/run/cybexos-live +ConditionPathExists=/var/lib/cybexos/major-upgrade/status.json + +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/cybexos-major-upgrade finalize-current +TimeoutStartSec=75min +UMask=0077 +# Rollback selection can succeed while the running session still needs a +# restart. Preserve that state for the explicit upgrade-system reboot action. +SuccessExitStatus=75 diff --git a/roles/base/files/cybexos-major-upgrade-validate.timer b/roles/base/files/cybexos-major-upgrade-validate.timer new file mode 100644 index 00000000..f4dd4376 --- /dev/null +++ b/roles/base/files/cybexos-major-upgrade-validate.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Check for the first desktop session after a Fedora major upgrade + +[Timer] +OnBootSec=1min +OnUnitActiveSec=1min +Unit=cybexos-major-upgrade-validate.service + +[Install] +WantedBy=timers.target diff --git a/roles/base/files/cybexos-system-snapshot b/roles/base/files/cybexos-system-snapshot index c99e7868..100399a8 100755 --- a/roles/base/files/cybexos-system-snapshot +++ b/roles/base/files/cybexos-system-snapshot @@ -820,7 +820,12 @@ def command_create(arguments: list[str]) -> int: # independent transactions. Snapshots and archives rotate together. snapshots = sorted(entry.name for entry in store.roots.iterdir() if entry.is_dir()) while len(snapshots) > KEEP: - oldest = snapshots.pop(0) + removable = [point for point in snapshots + if not (store.metadata / f"{point}.pin").exists()] + if not removable: + break + oldest = removable[0] + snapshots.remove(oldest) if not ID_PATTERN.match(oldest): raise Failure(f"refusing to prune unexpected snapshot name: {oldest}") run(["btrfs", "subvolume", "delete", str(store.roots / oldest)]) diff --git a/roles/base/files/cybexos-update-bootstrap b/roles/base/files/cybexos-update-bootstrap new file mode 100755 index 00000000..44f339bf --- /dev/null +++ b/roles/base/files/cybexos-update-bootstrap @@ -0,0 +1,282 @@ +#!/usr/bin/python3 +"""Safely introduce durable updates on installations predating the journal. + +The untouched legacy recovery point is required before deployment. A second +point includes a root-owned recovery bundle and login barrier, so boot recovery +survives an interrupted rollback even when the original system had no hook. +""" +from __future__ import annotations + +import argparse +import contextlib +import fcntl +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import types + +HOST = Path('/') +ROOT_UID = 0 +BUNDLES = Path('/usr/local/libexec/cybexos-update-bootstrap.d') +UNIT = Path('/etc/systemd/system/cybexos-update-recover.service') +VENDOR_UNIT = Path('/usr/lib/systemd/system/cybexos-update-recover.service') +FILES = ('cybexos-system-snapshot', 'cybexos-update-transaction', + 'cybexos-update-recover', 'cybexos-major-upgrade', + 'cybexos-update-bootstrap', 'cybexos-vendor-paths.json', + 'cybexos-update-recover.service', 'cybexos-update-recover-login.conf') +POINT = re.compile(r'^[0-9]{8}T[0-9]{6}Z-[0-9]+$') +IDENTIFIER = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$') +TERMINAL = {'committed', 'aborted', 'rolled-back'} + + +class Failure(Exception): + pass + + +def execute(command): + return subprocess.run(command, check=True, capture_output=True, text=True, + timeout=120).stdout.strip() + + +def secure(path): + """No root boot code or its parents may be writable by another account.""" + path = Path(path) + while True: + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or info.st_uid != ROOT_UID or info.st_mode & 0o022: + raise Failure(f'Unsafe privileged recovery path: {path}') + if path == HOST: + return + if HOST not in path.parents: + raise Failure('Recovery path escapes the system root') + path = path.parent + + +def directory(path): + if not path.exists(): + directory(path.parent) + path.mkdir(mode=0o755) + secure(path) + + +def complete(libexec): + for name in FILES[:6]: + path = libexec / name + secure(path) + if not path.is_file() or (not name.endswith('.json') and not os.access(path, os.X_OK)): + raise Failure(f'Incomplete recovery bundle: {path}') + + +def ready(libexec): + complete(libexec) + if execute(['systemctl', 'is-enabled', 'cybexos-update-recover.service']) != 'enabled': + raise Failure('Boot recovery is not enabled') + invocation = execute(['systemctl', 'show', 'cybexos-update-recover.service', + '-p', 'ExecStart', '--value']) + if f'path={libexec}/cybexos-update-recover ;' not in invocation: + raise Failure('Boot recovery does not use this installed bundle') + for unit in ('systemd-user-sessions.service', 'sddm.service'): + requirements = execute(['systemctl', 'show', unit, '-p', 'Requires', '--value']).split() + ordering = execute(['systemctl', 'show', unit, '-p', 'After', '--value']).split() + if 'cybexos-update-recover.service' not in requirements or 'cybexos-update-recover.service' not in ordering: + raise Failure(f'{unit} has no enforced recovery barrier') + + +def read_sources(source): + contents = {} + for name in FILES: + # The source is the explicitly selected checkout/release or installed + # helper set. Only these fixed regular files become privileged code. + with os.fdopen(os.open(source / name, os.O_RDONLY | os.O_NOFOLLOW), 'rb') as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise Failure(f'Recovery source is not a regular file: {name}') + contents[name] = stream.read(1024 * 1024 + 1) + if len(contents[name]) > 1024 * 1024: + raise Failure(f'Recovery source is too large: {name}') + return contents + + +def snapshot_module(contents): + module = types.ModuleType('cybexos_bootstrap_snapshot') + module.__file__ = str(BUNDLES / 'cybexos-system-snapshot') + exec(compile(contents['cybexos-system-snapshot'], module.__file__, 'exec'), module.__dict__) + return module + + +def validate_checkpoint(snapshot, point): + if not POINT.fullmatch(point): + raise Failure('Invalid pre-bootstrap checkpoint') + layout = snapshot.detect_layout() + if not layout.usable or layout.pending_reboot or layout.kind == 'recovery': + raise Failure('Bootstrap requires the active managed Btrfs root') + with snapshot.opened_store(layout, create=False) as store: + if store is None or not (store.roots / point).is_dir(): + raise Failure('The untouched pre-bootstrap recovery point is missing') + for journal in (store.path / 'transactions').glob('*/transaction.json'): + if json.loads(journal.read_text()).get('state') not in TERMINAL: + raise Failure('An earlier update must recover before bootstrapping') + + +def atomic_write(path, contents, mode=0o644): + directory(path.parent) + if path.is_symlink(): + raise Failure(f'Refusing to replace a recovery symlink: {path}') + descriptor, name = tempfile.mkstemp(prefix=f'.{path.name}.', dir=path.parent) + try: + with os.fdopen(descriptor, 'wb') as stream: + stream.write(contents) + stream.flush() + os.fchmod(stream.fileno(), mode) + os.fsync(stream.fileno()) + os.replace(name, path) + descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + Path(name).unlink(missing_ok=True) + + +def install_bundle(contents): + digest = hashlib.sha256() + for name in FILES: + digest.update(name.encode() + b'\0' + contents[name] + b'\0') + bundle = BUNDLES / digest.hexdigest() + directory(BUNDLES) + if bundle.exists(): + secure(bundle) + for name in FILES: + secure(bundle / name) + if (bundle / name).read_bytes() != contents[name]: + raise Failure('An existing recovery bundle has changed') + return bundle + staging = Path(tempfile.mkdtemp(prefix='.stage.', dir=BUNDLES)) + try: + for name in FILES: + mode = 0o644 if name.endswith(('.json', '.service', '.conf')) else 0o755 + atomic_write(staging / name, contents[name], mode) + staging.chmod(0o755) + os.rename(staging, bundle) + execute(['sync', '-f', str(BUNDLES)]) + finally: + if staging.exists(): + shutil.rmtree(staging) + return bundle + + +def prepare(source, point, identifier): + if not IDENTIFIER.fullmatch(identifier): + raise Failure('Invalid update identifier') + contents = read_sources(source) + snapshot = snapshot_module(contents) + # This validation deliberately precedes the first installed file write. + validate_checkpoint(snapshot, point) + bundle = install_bundle(contents) + unit = contents['cybexos-update-recover.service'].decode() + unit, count = re.subn(r'^ExecStart=/(?:usr/local|usr)/libexec/cybexos-update-recover$', + f'ExecStart={bundle}/cybexos-update-recover', unit, flags=re.MULTILINE) + if count != 1: + raise Failure('Unsupported recovery unit template') + atomic_write(UNIT, unit.encode()) + for relative in ('systemd-user-sessions.service.d/60-cybexos-update-recover.conf', + 'sddm.service.d/60-cybexos-update-recover.conf'): + atomic_write(UNIT.parent / relative, contents['cybexos-update-recover-login.conf']) + # Atomic replacements and a newly created bundle need their final labels + # before systemd can read/execute them under Fedora's enforcing policy. + execute(['restorecon', '-RF', str(bundle), str(UNIT), + str(UNIT.parent / 'systemd-user-sessions.service.d/60-cybexos-update-recover.conf'), + str(UNIT.parent / 'sddm.service.d/60-cybexos-update-recover.conf')]) + execute(['systemctl', 'daemon-reload']) + execute(['systemctl', 'enable', 'cybexos-update-recover.service']) + ready(bundle) + execute(['sync', '-f', str(UNIT)]) + # Calling the captured module avoids mixing APIs with the legacy helper. + output = io.StringIO() + with contextlib.redirect_stdout(output): + snapshot.command_create([f'update {identifier} (recovery bootstrap)']) + lines = output.getvalue().strip().splitlines() + checkpoint = lines[-1] if lines else '' + if not POINT.fullmatch(checkpoint) or checkpoint == point: + raise Failure('Bootstrap did not create a distinct protected recovery point') + return {'snapshot': checkpoint, 'previousSnapshot': point, + 'transactionHelper': str(bundle / 'cybexos-update-transaction')} + + +def finalize(bundle): + """Retire only our bootstrap files after normal helpers have converged.""" + if bundle.parent != BUNDLES or not re.fullmatch(r'[0-9a-f]{64}', bundle.name): + raise Failure('Invalid bootstrap bundle path') + secure(bundle) + snapshot = snapshot_module(read_sources(bundle)) + layout = snapshot.detect_layout() + if not layout.usable or layout.pending_reboot or layout.kind == 'recovery': + raise Failure('Recovery bootstrap must remain until the active root is healthy') + with snapshot.opened_store(layout, create=False) as store: + if store is None: + raise Failure('Recovery store is unavailable') + for journal in (store.path / 'transactions').glob('*/transaction.json'): + if json.loads(journal.read_text()).get('state') not in TERMINAL: + raise Failure('An unfinished update still needs boot recovery') + for libexec in (HOST / 'usr/local/libexec', HOST / 'usr/libexec'): + try: + complete(libexec) + bootstrap_line = f'ExecStart={bundle}/cybexos-update-recover' + if UNIT.is_file() and bootstrap_line in UNIT.read_text().splitlines(): + secure(VENDOR_UNIT) + if f'ExecStart={libexec}/cybexos-update-recover' not in VENDOR_UNIT.read_text().splitlines(): + continue + UNIT.unlink() + execute(['systemctl', 'daemon-reload']) + ready(libexec) + except (Failure, OSError, subprocess.SubprocessError): + continue + shutil.rmtree(bundle) + execute(['sync', '-f', str(BUNDLES)]) + return + # Package-only updates may not yet deliver the new role/RPM. This bundle + # remains the installed recovery implementation until full convergence. + print('Recovery bootstrap retained until normal recovery helpers converge', file=sys.stderr) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + check = commands.add_parser('ready') + check.add_argument('--libexec', type=Path, required=True) + bootstrap = commands.add_parser('prepare') + bootstrap.add_argument('--source', type=Path, required=True) + bootstrap.add_argument('--checkpoint', required=True) + bootstrap.add_argument('--id', required=True) + cleanup = commands.add_parser('finalize') + cleanup.add_argument('--bundle', type=Path, required=True) + args = parser.parse_args() + if os.geteuid() != 0: + parser.error('Run the recovery bootstrap as root') + os.umask(0o022) + try: + if args.command == 'ready': + ready(args.libexec) + else: + with Path('/run/cybexos-update-bootstrap.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + if args.command == 'prepare': + print(json.dumps(prepare(args.source, args.checkpoint, args.id))) + else: + finalize(args.bundle) + except Exception as error: + print(f'cybexos-update-bootstrap: {error}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/roles/base/files/cybexos-update-recover b/roles/base/files/cybexos-update-recover new file mode 100755 index 00000000..fcb19dcf --- /dev/null +++ b/roles/base/files/cybexos-update-recover @@ -0,0 +1,12 @@ +#!/usr/bin/bash +# Boot-only recovery: never expose a partially updated system to a new login. +set -uo pipefail +helper=$(dirname -- "$(readlink -f -- "$0")")/cybexos-update-transaction +"$helper" recover +result=$? +if ((result == 75)); then + systemctl --no-block reboot || exit 1 + # Keep the boot ordering barrier until shutdown takes over. + exec sleep infinity +fi +exit "$result" diff --git a/roles/base/files/cybexos-update-recover-login.conf b/roles/base/files/cybexos-update-recover-login.conf new file mode 100644 index 00000000..4eec9d18 --- /dev/null +++ b/roles/base/files/cybexos-update-recover-login.conf @@ -0,0 +1,4 @@ +[Unit] +# Ordering alone does not stop login when recovery fails. +Requires=cybexos-update-recover.service +After=cybexos-update-recover.service diff --git a/roles/base/files/cybexos-update-recover.service b/roles/base/files/cybexos-update-recover.service new file mode 100644 index 00000000..d57ba3fb --- /dev/null +++ b/roles/base/files/cybexos-update-recover.service @@ -0,0 +1,21 @@ +[Unit] +Description=Recover an interrupted CybexOS update before login +Wants=network-online.target +Requires=dbus.service +After=local-fs.target network-online.target dbus.service +RequiresMountsFor=/home /var +Before=systemd-user-sessions.service display-manager.service +Conflicts=shutdown.target +Before=shutdown.target +ConditionPathExists=!/run/cybexos-live +OnFailure=emergency.target +OnFailureJobMode=isolate + +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/cybexos-update-recover +TimeoutStartSec=75min +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/roles/base/files/cybexos-update-transaction b/roles/base/files/cybexos-update-transaction new file mode 100755 index 00000000..1690d31f --- /dev/null +++ b/roles/base/files/cybexos-update-transaction @@ -0,0 +1,433 @@ +#!/usr/bin/python3 +"""Durable OS and vendor-desktop rollback, independent of the root being restored. + +The transaction journal and vendor checkpoint live in the Btrfs top-level +recovery store, outside both root and home. Personal preferences are never +checkpointed. A failed transaction selects the previous root for the next boot; +it does not pretend to replace the running kernel or reboot a working session. +""" +from __future__ import annotations + +import argparse +import contextlib +import importlib.machinery +import importlib.util +import hashlib +import json +import os +from pathlib import Path +import pwd +import re +import shutil +import subprocess +import sys +import time + + +def load_snapshot(): + path = Path(__file__).resolve().with_name('cybexos-system-snapshot') + loader = importlib.machinery.SourceFileLoader('cybexos_snapshot', str(path)) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +snapshot = load_snapshot() +IDENTIFIER = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$') +ACTIVE = {'prepared', 'applying', 'validating', 'rolling-back', 'rollback-failed', + 'awaiting-upgrade', 'awaiting-desktop'} +TERMINAL = {'committed', 'aborted', 'rolled-back'} + + +def boot_id(): + return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + + +def execute(command, *, timeout=120): + result = subprocess.run(command, text=True, capture_output=True, timeout=timeout, + env={**os.environ, 'LC_ALL': 'C'}) + if result.returncode: + raise snapshot.Failure(f'{command[0]} failed: {result.stderr.strip()[-1000:]}') + return result.stdout.strip() + + +def as_user(uid, command): + account = pwd.getpwuid(uid) + return ['runuser', '-u', account.pw_name, '--', 'env', f'HOME={account.pw_dir}', + f'XDG_RUNTIME_DIR=/run/user/{uid}', + f'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{uid}/bus', *command] + + +def desktop_active(uid): + try: + return execute(as_user(uid, ['systemctl', '--user', 'is-active', + 'quickshell.service'])) == 'active' + except snapshot.Failure: + return False + + +def failed_units(): + return [line.split()[0] for line in + execute(['systemctl', '--failed', '--plain', '--no-legend']).splitlines() if line.strip()] + + +def health(record, *, desktop=True): + execute(['rpm', '--verifydb']) + new_failures = set(failed_units()) - set(record.get('failedUnits', [])) + if new_failures: + raise snapshot.Failure('New failed system units: ' + '; '.join(sorted(new_failures))) + if desktop and record.get('desktopActive'): + uid = record['uid'] + execute(as_user(uid, ['systemctl', '--user', 'daemon-reload'])) + execute(as_user(uid, ['systemctl', '--user', 'restart', 'quickshell.service'])) + account = pwd.getpwuid(uid) + runtime = Path(account.pw_dir) / '.local/bin/cybexos-runtime' + if not runtime.is_file(): + runtime = Path('/usr/share/cybexos/bin/cybexos-runtime') + deadline = time.monotonic() + 45 + stable_at = None + previous = None + while time.monotonic() < deadline: + try: + if not desktop_active(uid): + raise snapshot.Failure('Desktop service is not active') + invocation = execute(as_user(uid, ['systemctl', '--user', 'show', + 'quickshell.service', '-p', 'InvocationID', '--value'])) + pid = execute(as_user(uid, ['systemctl', '--user', 'show', 'quickshell.service', + '-p', 'MainPID', '--value'])) + if not re.fullmatch(r'[0-9a-f]{32}', invocation) or not pid.isdigit() or int(pid) == 0: + raise snapshot.Failure('Desktop process has not started') + if execute(['pgrep', '-u', str(uid), '-x', 'qs']).splitlines() != [pid]: + raise snapshot.Failure('The managed service must own the sole Quickshell process') + recovery = json.loads(execute(as_user(uid, [str(runtime), 'shell', 'status']))) + if recovery.get('safe'): + raise snapshot.Failure('The new desktop entered recovery mode') + # Type=simple readiness only means exec succeeded. Read-only + # IPC proves the full configuration finished constructing. + ready = json.loads(execute(as_user(uid, [str(runtime), 'ipc', 'settings', 'status']), + timeout=5)) + if not isinstance(ready.get('services'), dict): + raise snapshot.Failure('Desktop IPC did not report a ready configuration') + current = (pid, invocation) + if current != previous: + stable_at = time.monotonic() + previous = current + if stable_at is not None and time.monotonic() - stable_at >= 2: + break + except (snapshot.Failure, ValueError, subprocess.TimeoutExpired): + previous = None + stable_at = None + time.sleep(0.25) + else: + raise snapshot.Failure('The full desktop did not become stable and IPC-ready within 45 seconds') + journal = execute(as_user(uid, ['journalctl', '--user', '--no-pager', '-o', 'cat', + f'_SYSTEMD_INVOCATION_ID={invocation}'])) + if re.search(r'(ReferenceError:|TypeError:|SyntaxError:|Failed to load configuration)', journal): + raise snapshot.Failure('The updated desktop reported QML errors') + + +def save(directory, record): + record['updatedAt'] = int(time.time()) + snapshot.atomic_write(directory / 'transaction.json', json.dumps(record, indent=2) + '\n', 0o600) + execute(['sync', '-f', str(directory)]) + + +def read(directory): + value = json.loads((directory / 'transaction.json').read_text()) + if value.get('v') != 1 or value.get('state') not in ACTIVE | TERMINAL: + raise snapshot.Failure('Unsupported or corrupt transaction journal; refusing to overwrite it') + return value + + +def vendor_paths(): + path = Path(__file__).resolve().with_name('cybexos-vendor-paths.json') + values = json.loads(path.read_text()) + for relative in values: + parts = Path(relative).parts + if not parts or Path(relative).is_absolute() or '..' in parts: + raise snapshot.Failure('Unsafe vendor checkpoint manifest') + if relative.startswith(('.config/cybexos/', '.local/share/cybexos/plugins', + '.local/share/cybexos/themes', '.local/share/cybexos/plugin-data')): + raise snapshot.Failure('Personal data must never be part of the vendor checkpoint') + return values + + +def safe_destination(home, relative): + target = home / relative + # A user-controlled parent symlink must never redirect root writes elsewhere. + for parent in target.parents: + if parent == home: + break + if parent.is_symlink(): + raise snapshot.Failure(f'Vendor path has a symlink parent: {parent}') + return target + + +def home_command(uid, command): + # No privileged process traverses a writable home when copying/removing + # files. Checking symlink parents alone cannot prevent a rename race. + if uid == os.geteuid(): + return command + account = pwd.getpwuid(uid) + # Boot recovery runs before systemd-user-sessions. A PAM session may + # activate user@UID.service, which waits on that same login barrier. + # Filesystem work needs only the account's identity, never session setup. + return ['/usr/bin/setpriv', '--reuid', str(uid), '--regid', str(account.pw_gid), + '--init-groups', '--', '/usr/bin/env', f'HOME={account.pw_dir}', + f'USER={account.pw_name}', f'LOGNAME={account.pw_name}', *command] + + +def archive_path(directory, relative): + return directory / 'vendor' / (hashlib.sha256(relative.encode()).hexdigest() + '.tar') + + +def checkpoint(directory, home, uid): + present = [] + (directory / 'vendor').mkdir(mode=0o700) + for relative in vendor_paths(): + source = safe_destination(home, relative) + if source.exists() or source.is_symlink(): + with archive_path(directory, relative).open('xb') as stream: + subprocess.run(home_command(uid, ['/usr/bin/tar', '--acls', '--xattrs', + '--selinux', '--create', '--file=-', '--directory', str(home), '--', relative]), + stdout=stream, check=True, timeout=600) + stream.flush() + os.fsync(stream.fileno()) + present.append(relative) + return present + + +def restore_vendor(directory, record): + home = Path(pwd.getpwuid(record['uid']).pw_dir) + if str(home) != record['home']: + raise snapshot.Failure('Account home changed; refusing to restore into a different home') + for relative in record['paths']: + destination = safe_destination(home, relative) + backup = archive_path(directory, relative) + # Remove exactly the selected vendor object as its owner. A race can + # never turn this into a root write outside the account's privileges. + delete = ['/usr/bin/python3', '-I', '-c', + 'import pathlib,shutil,sys; p=pathlib.Path(sys.argv[1]); ' + 'shutil.rmtree(p) if p.is_dir() and not p.is_symlink() else p.unlink(missing_ok=True)', + str(destination)] + if relative in record['present']: + if not backup.is_file() or backup.is_symlink(): + raise snapshot.Failure(f'Vendor checkpoint is incomplete: {relative}') + execute(home_command(record['uid'], delete), timeout=600) + # Retain the archive until the journal is terminal. An interrupted + # extraction is safely retried, without exchanging roots twice. + with backup.open('rb') as stream: + subprocess.run(home_command(record['uid'], ['/usr/bin/tar', '--acls', '--xattrs', + '--selinux', '--extract', '--file=-', '--no-same-owner', '--same-permissions', + '--directory', str(home)]), stdin=stream, check=True, timeout=600) + else: + execute(home_command(record['uid'], delete), timeout=600) + + +def remove(path): + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink(missing_ok=True) + + +def cleanup_terminal(store, directory, record): + # A durable terminal journal is authoritative. A full disk or cleanup + # permission failure must not turn a successful restore into a retry that + # needs an archive which has already been partly removed. + for path in (directory / 'vendor', store.metadata / f"{record['snapshot']}.pin"): + try: + remove(path) + except OSError as error: + print(f'cybexos-update-transaction: retained cleanup artifact {path}: {error}', + file=sys.stderr) + + +@contextlib.contextmanager +def store_for_transaction(create=False): + layout = snapshot.detect_layout() + if not layout.usable: + raise snapshot.Failure(layout.message or 'Transactional updates require the managed Btrfs layout') + with snapshot.opened_store(layout, create=create) as store: + if store is None: + raise snapshot.Failure('Recovery store is missing') + yield layout, store + + +def begin(identifier, point, uid): + account = pwd.getpwuid(uid) + with store_for_transaction() as (layout, store): + if layout.pending_reboot or layout.kind == 'recovery': + raise snapshot.Failure('Restart into the selected system before updating') + if not snapshot.ID_PATTERN.fullmatch(point) or not (store.roots / point).is_dir(): + raise snapshot.Failure('The pre-update recovery point is missing') + root = store.path / 'transactions' + root.mkdir(mode=0o700, exist_ok=True) + for previous in root.glob('*/transaction.json'): + if read(previous.parent)['state'] in ACTIVE: + raise snapshot.Failure('Another transaction needs recovery before a new update') + directory = root / identifier + directory.mkdir(mode=0o700) + try: + record = {'v': 1, 'id': identifier, 'state': 'prepared', 'snapshot': point, + 'uid': uid, 'home': account.pw_dir, 'bootId': boot_id(), + 'paths': vendor_paths(), 'failedUnits': failed_units(), + 'desktopActive': desktop_active(uid), + 'rootUuid': snapshot.subvolume_uuid(store.top / 'root')} + record['present'] = checkpoint(directory, Path(account.pw_dir), uid) + save(directory, record) + except BaseException: + shutil.rmtree(directory) + raise + # Pin the point independently of normal five-point retention. + snapshot.atomic_write(store.metadata / f'{point}.pin', identifier + '\n', 0o600) + print(json.dumps(record)) + + +def transition(identifier, action, metadata=None): + with store_for_transaction() as (_layout, store): + directory = store.path / 'transactions' / identifier + record = read(directory) + if action == 'status': + print(json.dumps(record)) + return + if action == 'applying': + if record['state'] not in {'prepared', 'awaiting-upgrade'}: + raise snapshot.Failure('Only a prepared update may start applying') + record['state'] = 'applying' + elif action == 'commit': + if record['state'] not in {'applying', 'validating', 'awaiting-desktop'}: + raise snapshot.Failure('Only an applied update may be validated') + record['state'] = 'validating' + save(directory, record) + health(record) + record['state'] = 'committed' + elif action == 'await-desktop': + if record['state'] not in {'applying', 'validating'}: + raise snapshot.Failure('Only a converged upgrade can await desktop validation') + health(record, desktop=False) + record.update(state='awaiting-desktop', desktopActive=True) + elif action == 'abort': + if record['state'] not in {'prepared', 'awaiting-upgrade'}: + raise snapshot.Failure('An applied update must be rolled back') + record['state'] = 'aborted' + elif action == 'arm-upgrade': + if record['state'] != 'prepared' or not isinstance(metadata, dict): + raise snapshot.Failure('A major upgrade requires a prepared checkpoint and target metadata') + target = metadata.get('targetFedora') + if not isinstance(target, str) or not re.fullmatch(r'[1-9][0-9]{1,2}', target): + raise snapshot.Failure('Major upgrade targetFedora must be a release number string') + record.update(state='awaiting-upgrade', operation='major-upgrade', upgrade=metadata) + save(directory, record) + if record['state'] in {'committed', 'aborted'}: + cleanup_terminal(store, directory, record) + print(json.dumps(record)) + + +def rollback(identifier): + # Do not hold the store's flock while the snapshot helper acquires it. + with store_for_transaction() as (_layout, store): + directory = store.path / 'transactions' / identifier + record = read(directory) + if record['state'] in {'committed', 'aborted'}: + raise snapshot.Failure('A completed transaction cannot be automatically rolled back') + if record['state'] == 'rolled-back': + print(json.dumps(record)) + return + record['state'] = 'rolling-back' + save(directory, record) + # A crash after root exchange must not exchange it a second time. + snapshot.complete_interrupted(store) + already_restored = any(item.get('point') == record['snapshot'] + and item.get('state') == 'complete' + for item in (snapshot.read_record(store, path.stem) + for path in store.replaced.glob('*.json'))) + try: + if not already_restored: + with contextlib.redirect_stdout(sys.stderr): + snapshot.command_restore([record['snapshot']]) + with store_for_transaction() as (_layout, store): + directory = store.path / 'transactions' / identifier + restore_vendor(directory, record) + record['state'] = 'rolled-back' + record['restartRequired'] = snapshot.detect_layout().pending_reboot + save(directory, record) + cleanup_terminal(store, directory, record) + except BaseException: + with store_for_transaction() as (_layout, store): + record['state'] = 'rollback-failed' + save(store.path / 'transactions' / identifier, record) + raise + print(json.dumps(record)) + + +def recover(): + layout = snapshot.detect_layout() + if not layout.usable: + return + with snapshot.opened_store(layout, create=False) as store: + if store is None: + return + pending = [read(path.parent) for path in (store.path / 'transactions').glob('*/transaction.json')] + for record in pending: + # Installing the login dependency can start this oneshot during an + # existing session. Only another boot proves that the update worker + # was interrupted; never restore files beneath a same-boot worker. + if record['bootId'] == boot_id(): + continue + if record['state'] == 'awaiting-upgrade': + helper = Path(__file__).resolve().with_name('cybexos-major-upgrade') + result = subprocess.run([str(helper), 'finalize', record['id']], check=False, timeout=4200) + if result.returncode == 75: + raise SystemExit(75) + if result.returncode: + rollback(record['id']) + raise SystemExit(75) + elif record['state'] == 'awaiting-desktop': + continue + elif record['state'] == 'prepared': + transition(record['id'], 'abort') + elif record['state'] in ACTIVE: + rollback(record['id']) + # Boot recovery runs before logins. Do not start a desktop against + # the still-mounted failed root; return a distinct restart signal. + raise SystemExit(75) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + begin_parser = commands.add_parser('begin') + begin_parser.add_argument('id') + begin_parser.add_argument('snapshot') + begin_parser.add_argument('--uid', required=True, type=int) + for action in ('applying', 'commit', 'abort', 'rollback', 'status', 'await-desktop'): + commands.add_parser(action).add_argument('id') + arm = commands.add_parser('arm-upgrade') + arm.add_argument('id') + arm.add_argument('--metadata', type=json.loads, required=True) + commands.add_parser('recover') + args = parser.parse_args(argv) + if hasattr(args, 'id') and not IDENTIFIER.fullmatch(args.id): + parser.error('Invalid transaction identifier') + if os.geteuid() != 0: + parser.error('Run the transaction helper as root') + os.umask(0o077) + try: + if args.command == 'begin': + begin(args.id, args.snapshot, args.uid) + elif args.command == 'recover': + recover() + elif args.command == 'rollback': + rollback(args.id) + else: + transition(args.id, args.command, getattr(args, 'metadata', None)) + except (snapshot.Failure, OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print(f'cybexos-update-transaction: {error}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/roles/base/files/cybexos-vendor-paths.json b/roles/base/files/cybexos-vendor-paths.json new file mode 100644 index 00000000..7cd8e9fb --- /dev/null +++ b/roles/base/files/cybexos-vendor-paths.json @@ -0,0 +1,76 @@ +[ + ".agents/skills/cybexos", + ".claude/skills/cybexos", + ".codex/skills/cybexos", + ".config/environment.d/10-cybexos.conf", + ".config/systemd/user/cybexos-input-method.service", + ".config/systemd/user/cybexos-session-lock.service", + ".config/systemd/user/external-monitor-toggle.service", + ".config/systemd/user/graphical-session.target.wants/hyprpolkitagent.service", + ".config/systemd/user/hermes-menubar-bridge.service", + ".config/systemd/user/hypridle.service", + ".config/systemd/user/hyprland-session.target", + ".config/systemd/user/hyprland-session.target.wants/cybexos-input-method.service", + ".config/systemd/user/hyprland-session.target.wants/external-monitor-toggle.service", + ".config/systemd/user/hyprland-session.target.wants/hermes-menubar-bridge.service", + ".config/systemd/user/hyprland-session.target.wants/hypridle.service", + ".config/systemd/user/hyprland-session.target.wants/hyprpolkitagent.service", + ".config/systemd/user/hyprland-session.target.wants/quickshell.service", + ".config/systemd/user/hyprland-session.target.wants/voxtype.service", + ".config/systemd/user/hyprpolkitagent.service", + ".config/systemd/user/quickshell.service", + ".config/systemd/user/voxtype.service", + ".config/systemd/user/xps-speaker-tuning.service", + ".config/xdg-desktop-portal/hyprland-portals.conf", + ".local/bin/app-backup", + ".local/bin/brightness-control", + ".local/bin/browser-backup", + ".local/bin/clipboard-copy-image", + ".local/bin/clipboard-history-store", + ".local/bin/clipboard-image-to-file", + ".local/bin/cybex", + ".local/bin/cybexos-agent", + ".local/bin/cybexos-firmware-update", + ".local/bin/cybexos-release-update", + ".local/bin/cybexos-runtime", + ".local/bin/cybexos-update-run", + ".local/bin/dev-arch-shell", + ".local/bin/dev-debian-shell", + ".local/bin/dev-distrobox-init", + ".local/bin/dev-fedora-shell", + ".local/bin/external-monitor-toggle", + ".local/bin/fastfetch-link-speed", + ".local/bin/localsend", + ".local/bin/localsend-share", + ".local/bin/portal-launcher", + ".local/bin/quickshell-reminder", + ".local/bin/screen-ocr", + ".local/bin/screen-record", + ".local/bin/screenshot", + ".local/bin/spotify", + ".local/bin/t3code-desktop", + ".local/bin/t3code-update", + ".local/bin/update-user-tools", + ".local/libexec/cybex_hermes", + ".local/libexec/cybexos-migrate-layering", + ".local/libexec/hermes-menubar-bridge", + ".local/share/applications/1password.desktop", + ".local/share/applications/brave-browser.desktop", + ".local/share/applications/brave-origin.desktop", + ".local/share/applications/chatgpt.desktop", + ".local/share/applications/com.onepassword.OnePassword.desktop", + ".local/share/applications/dev-arch.desktop", + ".local/share/applications/dev-debian.desktop", + ".local/share/applications/dev-fedora.desktop", + ".local/share/applications/localsend-share-clipboard.desktop", + ".local/share/applications/localsend-share-file.desktop", + ".local/share/applications/localsend-share-folder.desktop", + ".local/share/applications/nvim-kitty.desktop", + ".local/share/applications/t3code-nightly.desktop", + ".local/share/cybexos/current", + ".local/share/cybexos/releases/bootstrap", + ".local/share/cybexos/runtime", + ".local/share/nautilus-python/extensions/localsend.py", + ".local/state/cybexos/quickshell-manifest.txt", + ".local/state/cybexos/shell-recovery.json" +] diff --git a/roles/base/tasks/main.yml b/roles/base/tasks/main.yml index 76ef5564..032346c5 100644 --- a/roles/base/tasks/main.yml +++ b/roles/base/tasks/main.yml @@ -139,6 +139,78 @@ mode: "0755" register: base_snapshot_helper +- name: Install the durable update transaction helpers + ansible.builtin.copy: + src: "{{ item }}" + dest: "/usr/local/libexec/{{ item }}" + owner: root + group: root + mode: "{{ '0644' if item.endswith(('.json', '.service', '.conf')) else '0755' }}" + loop: + - cybexos-update-transaction + - cybexos-update-bootstrap + - cybexos-update-recover + - cybexos-update-recover.service + - cybexos-update-recover-login.conf + - cybexos-vendor-paths.json + - cybexos-major-upgrade + +- name: Install the interrupted-update boot recovery unit + ansible.builtin.copy: + src: cybexos-update-recover.service + dest: /etc/systemd/system/cybexos-update-recover.service + owner: root + group: root + mode: "0644" + register: base_update_recover_unit + +- name: Create the login recovery dependency directories + ansible.builtin.file: + path: "/etc/systemd/system/{{ item }}.d" + state: directory + owner: root + group: root + mode: "0755" + loop: + - systemd-user-sessions.service + - sddm.service + +- name: Block login if interrupted-update recovery fails + ansible.builtin.copy: + src: cybexos-update-recover-login.conf + dest: "/etc/systemd/system/{{ item }}.d/60-cybexos-update-recover.conf" + owner: root + group: root + mode: "0644" + loop: + - systemd-user-sessions.service + - sddm.service + register: base_update_recover_dependencies + +- name: Enable interrupted-update recovery for subsequent boots + ansible.builtin.systemd_service: + name: cybexos-update-recover.service + enabled: true + daemon_reload: "{{ base_update_recover_unit.changed or base_update_recover_dependencies.changed }}" + +- name: Install Fedora upgrade validation units + ansible.builtin.copy: + src: "{{ item }}" + dest: "/etc/systemd/system/{{ item }}" + owner: root + group: root + mode: "0644" + loop: + - cybexos-major-upgrade-validate.service + - cybexos-major-upgrade-validate.timer + register: base_major_upgrade_units + +- name: Enable Fedora upgrade validation after the first desktop login + ansible.builtin.systemd_service: + name: cybexos-major-upgrade-validate.timer + enabled: true + daemon_reload: "{{ base_major_upgrade_units.changed }}" + # The documented `sudo cybexos-system-snapshot ...` form needs the helper on # sudo's secure_path; libexec is not on it. - name: Expose the recovery helper to administrators diff --git a/roles/desktop/files/90-cybexos-smb.gschema.override b/roles/desktop/files/90-cybexos-smb.gschema.override new file mode 100644 index 00000000..8915f082 --- /dev/null +++ b/roles/desktop/files/90-cybexos-smb.gschema.override @@ -0,0 +1,4 @@ +# Files/GVfs uses this workgroup for new SMB connections. +# Explicit per-user GSettings values take precedence. +[org.gnome.system.smb] +workgroup='WORKGROUP' diff --git a/roles/desktop/files/autostart.lua b/roles/desktop/files/autostart.lua index 4cd7f28e..743eaf8f 100644 --- a/roles/desktop/files/autostart.lua +++ b/roles/desktop/files/autostart.lua @@ -2,5 +2,14 @@ hl.on("hyprland.start", function() -- One process serializes environment publication, target activation, and -- portal refresh. Separate async execs race target units against a partially -- imported environment on a fresh login. - hl.exec_cmd([[/usr/local/libexec/cybexos-hyprland-session-start]]) + -- Development mode loads this file verbatim even on an ISO installation, + -- where the packaged helper lives in /usr/libexec. Resolve the installed + -- helper at startup instead of relying on the image's path rewriting. + hl.exec_cmd([[ + starter=/usr/local/libexec/cybexos-hyprland-session-start + if [ ! -x "$starter" ]; then + starter=/usr/libexec/cybexos-hyprland-session-start + fi + exec "$starter" + ]]) end) diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/__init__.py b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/__init__.py new file mode 100644 index 00000000..4d8f31d4 --- /dev/null +++ b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/__init__.py @@ -0,0 +1,4 @@ +"""Hermes bridge protocol, credential transport and durable registry. + +The entrypoint owns conversation orchestration; these modules do not import it. +""" diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/auth.py b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/auth.py new file mode 100644 index 00000000..c7939b1c --- /dev/null +++ b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/auth.py @@ -0,0 +1,1249 @@ +"""Origin-scoped HTTP credentials and authenticated remote transport.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from http.cookiejar import Cookie, CookieJar +import json +import os +from pathlib import Path +import re +import threading +import time +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin, urlparse, urlunparse +from urllib.request import ( + HTTPRedirectHandler, + HTTPCookieProcessor, + Request, + build_opener, +) +import uuid + + +from .protocol import ( + LOG, + MAX_REMOTE_AUTH_RESPONSE, + MAX_REMOTE_ATTACHMENT_BYTES, + REMOTE_AUTH_VERSION, + RpcFault, + AmbiguousDelivery, + utc_now, + is_loopback, +) + +class _RemoteAuthRequired(Exception): + """A remote response is an authentication challenge, not API data.""" + + def __init__(self, status_code: int = 401): + super().__init__("remote authentication required") + self.status_code = status_code + + +class _RemoteRedirectBlocked(Exception): + """A redirect attempted to leave the configured WebUI origin.""" + + +class _RemoteTransportError(Exception): + """The remote WebUI could not be reached.""" + + +def _remote_origin(url: str) -> tuple[str, str, int]: + parsed = urlparse(url) + try: + port = parsed.port + except ValueError as exc: + raise RpcFault(-32602, "Remote Hermes URL has an invalid port") from exc + scheme = parsed.scheme.lower() + hostname = (parsed.hostname or "").lower().rstrip(".") + if not hostname or scheme not in {"http", "https"}: + raise RpcFault(-32602, "Remote Hermes URL must use http:// or https://") + return scheme, hostname, port or (443 if scheme == "https" else 80) + + +def normalize_remote_url(raw: Any) -> str: + """Validate and canonicalize a user-provided Hermes WebUI base URL.""" + + if not isinstance(raw, str) or not raw.strip(): + raise RpcFault(-32602, "Remote Hermes URL is required") + value = raw.strip().rstrip("/") + if len(value) > 2048 or any(ord(character) < 0x20 for character in value): + raise RpcFault(-32602, "Remote Hermes URL is invalid") + if any(character.isspace() or character == "\\" for character in value): + raise RpcFault(-32602, "Remote Hermes URL must not contain whitespace") + try: + parsed = urlparse(value) + hostname = parsed.hostname + port = parsed.port + except ValueError as exc: + raise RpcFault(-32602, "Remote Hermes URL is invalid") from exc + scheme = parsed.scheme.lower() + if ( + scheme not in {"http", "https"} + or not hostname + or parsed.username is not None + or parsed.password is not None + or parsed.params + or parsed.query + or parsed.fragment + ): + raise RpcFault( + -32602, + "Use an http(s) Hermes URL without credentials, query, or fragment", + ) + if scheme == "http" and not is_loopback(hostname): + raise RpcFault( + -32602, + "Remote Hermes URLs must use HTTPS; HTTP is allowed only on loopback", + ) + try: + ascii_hostname = hostname.rstrip(".").encode("idna").decode("ascii").lower() + except UnicodeError as exc: + raise RpcFault(-32602, "Remote Hermes URL has an invalid hostname") from exc + host_for_netloc = ( + f"[{ascii_hostname}]" if ":" in ascii_hostname else ascii_hostname + ) + default_port = 443 if scheme == "https" else 80 + if port is not None and port != default_port: + host_for_netloc = f"{host_for_netloc}:{port}" + path = parsed.path.rstrip("/") + decoded_segments = [ + segment.lower().replace("%2e", ".") for segment in path.split("/") + ] + if any(segment in {".", ".."} for segment in decoded_segments): + raise RpcFault(-32602, "Remote Hermes URL path must not traverse directories") + normalized = urlunparse((scheme, host_for_netloc, path, "", "", "")) + _remote_origin(normalized) + return normalized + + +def _is_login_url(url: str) -> bool: + try: + path = urlparse(url).path.rstrip("/").lower() + except ValueError: + return False + if path.endswith("/api/auth/login") or path.endswith("/api/auth/passkey/login"): + return False + return path == "/login" or path.endswith("/login") + + +class _SameOriginRedirectHandler(HTTPRedirectHandler): + """Follow only redirects that remain on the originally requested origin.""" + + max_redirections = 5 + + def __init__(self, allowed_origin: tuple[str, str, int]): + super().__init__() + self.allowed_origin = allowed_origin + self.redirects: list[str] = [] + + def redirect_request( + self, + request: Request, + file_pointer: Any, + code: int, + message: str, + headers: Any, + new_url: str, + ) -> Request | None: + target = urljoin(request.full_url, new_url) + # Login redirects are a normal expired-session signal. Do not follow + # them, even when a reverse proxy points at a different origin. + if _is_login_url(target): + raise _RemoteAuthRequired(code) + parsed = urlparse(target) + if parsed.username is not None or parsed.password is not None: + raise _RemoteRedirectBlocked() + try: + target_origin = _remote_origin(target) + except RpcFault as exc: + raise _RemoteRedirectBlocked() from exc + if target_origin != self.allowed_origin: + raise _RemoteRedirectBlocked() + self.redirects.append(target) + return super().redirect_request( + request, file_pointer, code, message, headers, target + ) + + +class RemoteLoginFault(RpcFault): + def __init__(self, message: str, status: dict[str, Any], code: int = -32040): + super().__init__(code, message, status) + + +class RemoteWebUIAuth: + """Origin-bound Hermes WebUI cookie session manager. + + The password is used only to build one in-memory login request. The file + contains the normalized origin and cookies issued by that origin; it never + contains a password, request body, or server response body. + + ``_lock`` guards only the in-memory configuration (origin, cookie jar, + source, and status) and the credential file. It is never held across a + network request: the event loop reads ``status`` constantly, so one slow + or unreachable WebUI request would otherwise stall every local RPC, + stream relay, and keepalive for its whole timeout, and serialize all + remote traffic behind it. Requests snapshot the configuration, run + unlocked, and apply an expiry only if that configuration is still current. + """ + + def __init__(self, path: Path, environment_url: str | None = None): + self.path = path + self._lock = threading.RLock() + self.cookie_jar = CookieJar() + self.base_url = "" + self.source = "none" + self.environment_url = "" + self._status = self._make_status( + "disconnected", message="Remote Hermes is not configured" + ) + configured_environment = ( + environment_url + if environment_url is not None + else os.environ.get("HERMES_REMOTE_URL", "") + ) + if configured_environment: + try: + self.environment_url = normalize_remote_url(configured_environment) + except RpcFault: + self._status = self._make_status( + "error", + message="HERMES_REMOTE_URL is invalid", + error_kind="configuration", + ) + file_exists = self.path.exists() + if file_exists: + self._load() + elif self.environment_url: + self.base_url = self.environment_url + self.source = "environment" + self._status = self._make_status( + "disconnected", + configured=True, + url=self.base_url, + message="Remote Hermes has not been checked", + ) + + @property + def status(self) -> dict[str, Any]: + # Writers replace ``_status`` wholesale under ``_lock``, so copying the + # current reference always yields one complete status. Reading it + # lock-free keeps the event loop off a writer's critical section. + return dict(self._status) + + @staticmethod + def _jar_cookies(jar: CookieJar) -> list[Cookie]: + # Requests update a shared jar from worker threads under the jar's + # own lock. Iterate under that lock as well, so a concurrent + # Set-Cookie cannot resize its dictionaries mid-iteration. + with jar._cookies_lock: + return list(jar) + + def connecting_status(self, message: str, url: Any = None) -> dict[str, Any]: + with self._lock: + display_url = self.base_url + if url: + display_url = normalize_remote_url(url) + self._status = self._make_status( + "connecting", + configured=bool(display_url), + url=display_url, + message=message, + ) + return dict(self._status) + + def _make_status( + self, + state: str, + *, + configured: bool | None = None, + url: str | None = None, + reachable: bool = False, + auth_enabled: bool = False, + authenticated: bool = False, + logged_in: bool = False, + password_auth_enabled: bool = False, + message: str = "", + error_kind: str = "", + status_code: int = 0, + source: str | None = None, + ) -> dict[str, Any]: + selected_url = self.base_url if url is None else url + selected_configured = bool(selected_url) if configured is None else configured + return { + "state": state, + "configured": selected_configured, + "url": selected_url, + "origin": selected_url, + "reachable": reachable, + "authEnabled": auth_enabled, + "authenticated": authenticated, + "loggedIn": logged_in, + "passwordAuthEnabled": password_auth_enabled, + "authRequired": state == "expired", + "hasSessionCredential": bool(self._jar_cookies(self.cookie_jar)), + "source": self.source if source is None else source, + "message": message, + "error": message if state in {"expired", "error"} else "", + "errorKind": error_kind, + "statusCode": status_code, + "updatedAt": utc_now(), + } + + def _load(self) -> None: + try: + document = json.loads(self.path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("credential document is not an object") + base_url = normalize_remote_url(document.get("base_url")) + rows = document.get("cookies", []) + if not isinstance(rows, list): + raise ValueError("credential cookie list is invalid") + jar = CookieJar() + for row in rows: + cookie = self._cookie_from_row(row, base_url) + if cookie is not None: + jar.set_cookie(cookie) + self.base_url = base_url + self.cookie_jar = jar + self.source = "persisted" + with suppress(OSError): + os.chmod(self.path, 0o600) + self._status = self._make_status( + "disconnected", + configured=True, + url=base_url, + message="Saved remote session has not been checked", + ) + except FileNotFoundError: + return + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError, RpcFault): + # Never include credential document contents in diagnostics. + LOG.warning("could not load remote Hermes credentials from %s", self.path) + self.base_url = "" + self.cookie_jar = CookieJar() + self.source = "none" + self._status = self._make_status( + "error", + configured=False, + url="", + message="Saved remote Hermes credentials are invalid", + error_kind="credentials", + ) + + @staticmethod + def _cookie_from_row(row: Any, base_url: str) -> Cookie | None: + if not isinstance(row, dict): + return None + name = row.get("name") + value = row.get("value") + domain = str(row.get("domain") or "").lstrip(".").lower().rstrip(".") + origin_host = (urlparse(base_url).hostname or "").lower().rstrip(".") + path = str(row.get("path") or "/") + if ( + not isinstance(name, str) + or not name + or len(name) > 256 + or not isinstance(value, str) + or len(value) > 16384 + or any(ord(character) < 0x20 for character in name + value) + or domain != origin_host + or not path.startswith("/") + or len(path) > 2048 + ): + return None + expires_raw = row.get("expires") + try: + expires = int(expires_raw) if expires_raw is not None else None + except (TypeError, ValueError): + return None + if expires is not None and expires <= int(time.time()): + return None + return Cookie( + version=0, + name=name, + value=value, + port=None, + port_specified=False, + domain=domain, + domain_specified=bool(row.get("domain_specified", False)), + domain_initial_dot=False, + path=path, + path_specified=True, + secure=bool(row.get("secure", False)), + expires=expires, + discard=expires is None, + comment=None, + comment_url=None, + rest={"HttpOnly": None} if row.get("http_only", True) else {}, + rfc2109=False, + ) + + def _cookie_rows(self, base_url: str, jar: CookieJar) -> list[dict[str, Any]]: + origin_host = (urlparse(base_url).hostname or "").lower().rstrip(".") + now = time.time() + rows: list[dict[str, Any]] = [] + for cookie in self._jar_cookies(jar): + if cookie.is_expired(now) or cookie.domain.lstrip(".").lower() != origin_host: + continue + rows.append( + { + "name": cookie.name, + "value": cookie.value, + "domain": origin_host, + "domain_specified": cookie.domain_specified, + "path": cookie.path or "/", + "secure": cookie.secure, + "expires": cookie.expires, + "http_only": "HttpOnly" in cookie._rest, + } + ) + return rows + + def _save(self) -> None: + if not self.base_url: + self._delete_file() + return + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + with suppress(OSError): + os.chmod(self.path.parent, 0o700) + document = { + "version": REMOTE_AUTH_VERSION, + "base_url": self.base_url, + "cookies": self._cookie_rows(self.base_url, self.cookie_jar), + } + temporary = self.path.with_name(f".{self.path.name}.tmp.{os.getpid()}") + descriptor = os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(document, stream, ensure_ascii=False, separators=(",", ":")) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, self.path) + os.chmod(self.path, 0o600) + finally: + with suppress(FileNotFoundError): + temporary.unlink() + + def _delete_file(self) -> None: + try: + self.path.unlink() + except FileNotFoundError: + return + except OSError as exc: + raise RpcFault(-32043, "Could not remove saved remote session") from exc + + @staticmethod + def _login_page_response(response: dict[str, Any]) -> bool: + if response["status"] == 401 or _is_login_url(response["url"]): + return True + location = response["headers"].get("Location", "") + if location and _is_login_url(urljoin(response["url"], location)): + return True + content_type = response["headers"].get("Content-Type", "").lower() + if "text/html" not in content_type: + return False + sample = response["body"][:256 * 1024].decode("utf-8", errors="ignore").lower() + return ( + "login.js" in sample + or "/api/auth/login" in sample + or ("sign in" in sample and "hermes" in sample) + ) + + def _request( + self, + base_url: str, + jar: CookieJar, + method: str, + path: str, + payload: dict[str, Any] | None, + timeout: float, + *, + encoded_body: bytes | None = None, + content_type: str = "", + ) -> dict[str, Any]: + if not path.startswith("/") or "://" in path: + raise RpcFault(-32602, "Remote Hermes API path is invalid") + body = encoded_body + headers = { + "Accept": "application/json", + "User-Agent": "cybexos-hermes-menubar-bridge/1", + } + if encoded_body is not None: + if not content_type: + raise RpcFault(-32602, "Remote Hermes request content type is required") + headers["Content-Type"] = content_type + elif payload is not None: + body = json.dumps( + payload, ensure_ascii=False, separators=(",", ":") + ).encode("utf-8") + headers["Content-Type"] = "application/json" + redirect_handler = _SameOriginRedirectHandler(_remote_origin(base_url)) + opener = build_opener(redirect_handler, HTTPCookieProcessor(jar)) + request = Request( + f"{base_url}{path}", body, headers=headers, method=method.upper() + ) + try: + with opener.open(request, timeout=timeout) as response: + raw = response.read(MAX_REMOTE_AUTH_RESPONSE + 1) + result = { + "status": int(response.status), + "url": response.geturl(), + "headers": response.headers, + "body": raw, + "redirects": list(redirect_handler.redirects), + } + except (_RemoteAuthRequired, _RemoteRedirectBlocked): + raise + except HTTPError as exc: + raw = exc.read(MAX_REMOTE_AUTH_RESPONSE + 1) + result = { + "status": int(exc.code), + "url": exc.geturl(), + "headers": exc.headers, + "body": raw, + "redirects": list(redirect_handler.redirects), + } + except (URLError, TimeoutError, OSError) as exc: + raise _RemoteTransportError() from exc + if len(result["body"]) > MAX_REMOTE_AUTH_RESPONSE: + raise RpcFault(-32041, "Remote Hermes response is too large") + if self._login_page_response(result): + raise _RemoteAuthRequired(result["status"]) + return result + + @staticmethod + def _json_response(response: dict[str, Any]) -> dict[str, Any]: + try: + value = json.loads(response["body"].decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError) as exc: + raise RpcFault(-32041, "Remote Hermes returned invalid JSON") from exc + if not isinstance(value, dict): + raise RpcFault(-32041, "Remote Hermes returned invalid JSON") + return value + + @classmethod + def _http_error_fault(cls, response: dict[str, Any]) -> RpcFault: + """Translate a bounded WebUI error without reflecting secrets. + + Hermes WebUI returns typed JSON for recoverable conflicts. Only a + small scalar allow-list crosses the loopback RPC boundary; arbitrary + response objects, headers, cookies, and request content never do. + """ + + status_code = int(response.get("status") or 0) + data: dict[str, Any] = {"statusCode": status_code} + try: + value = cls._json_response(response) + except RpcFault: + value = {} + + error_type = str(value.get("type") or "").strip().lower() + if re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", error_type): + data["errorType"] = error_type + else: + error_type = "" + + error_code = str(value.get("code") or "").strip().lower() + if re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", error_code): + data["errorCode"] = error_code + else: + error_code = "" + + if isinstance(value.get("retryable"), bool): + data["retryable"] = value["retryable"] + + active_stream_id = str(value.get("active_stream_id") or "").strip() + if re.fullmatch(r"[A-Za-z0-9_-]{1,128}", active_stream_id): + data["activeStreamId"] = active_stream_id + else: + active_stream_id = "" + + raw_message = value.get("error") + if not isinstance(raw_message, str): + raw_message = value.get("message") + if not isinstance(raw_message, str): + raw_message = "" + remote_message = re.sub(r"\s+", " ", "".join( + character for character in raw_message + if ord(character) >= 0x20 and ord(character) != 0x7f + )).strip()[:500] + sensitive_words = re.compile( + r"password|passphrase|api[ _-]?key|authorization|cookie|secret|token", + re.IGNORECASE, + ) + if remote_message and not sensitive_words.search(remote_message): + data["remoteMessage"] = remote_message + + if error_type == "agent_runtime_stale": + message = ( + "Remote Hermes fell back to a stale in-process Agent runtime. " + "Restore gateway-backed chat, then retry; this prompt was not accepted." + ) + elif active_stream_id or error_type in { + "active_stream", + "chat_already_running", + "session_busy", + "stream_conflict", + }: + message = ( + "This Hermes session already has an active response; " + "the new prompt was not accepted." + ) + elif error_code == "stale_regeneration_revision": + message = "This conversation changed; refresh it before regenerating" + elif error_code == "unsupported_regeneration_backend": + message = "Regeneration is unavailable on this Hermes backend" + elif error_code == "invalid_regeneration_request": + message = "Hermes rejected the regeneration request" + else: + message = f"Remote Hermes returned HTTP {status_code}" + + return RpcFault(-32041, message, data) + + def _probe_base( + self, + base_url: str, + jar: CookieJar, + *, + configured: bool, + source: str, + timeout: float, + ) -> dict[str, Any]: + # The source is passed through rather than set on ``self``: probes run + # without ``_lock``, so shared state must not change for their sake. + try: + response = self._request( + base_url, jar, "GET", "/api/auth/status", None, timeout + ) + if not 200 <= response["status"] < 300: + return self._make_status( + "error", + configured=configured, + url=base_url, + source=source, + reachable=True, + message=f"Remote Hermes returned HTTP {response['status']}", + error_kind="http", + status_code=response["status"], + ) + value = self._json_response(response) + if "auth_enabled" not in value or "logged_in" not in value: + raise RpcFault(-32041, "Remote endpoint is not a compatible Hermes WebUI") + auth_enabled = value.get("auth_enabled") is True + logged_in = value.get("logged_in") is True + connected = not auth_enabled or logged_in + return self._make_status( + "connected" if connected else "expired", + configured=configured, + url=base_url, + source=source, + reachable=True, + auth_enabled=auth_enabled, + authenticated=connected, + logged_in=logged_in, + password_auth_enabled=value.get("password_auth_enabled") is True, + message=( + "Remote Hermes is connected" + if connected + else "Remote Hermes authentication is required" + ), + ) + except _RemoteAuthRequired as exc: + return self._make_status( + "expired", + configured=configured, + url=base_url, + source=source, + reachable=True, + auth_enabled=True, + message="Remote Hermes authentication is required", + status_code=exc.status_code, + ) + except _RemoteRedirectBlocked: + return self._make_status( + "error", + configured=configured, + url=base_url, + source=source, + reachable=True, + message="Remote Hermes attempted a cross-origin redirect", + error_kind="redirect", + ) + except _RemoteTransportError: + return self._make_status( + "error", + configured=configured, + url=base_url, + source=source, + reachable=False, + message="Remote Hermes is unreachable", + error_kind="offline", + ) + except RpcFault as fault: + return self._make_status( + "error", + configured=configured, + url=base_url, + source=source, + reachable=True, + message=fault.message, + error_kind="protocol", + ) + + async def probe(self, url: Any = None, timeout: float = 10.0) -> dict[str, Any]: + return await asyncio.to_thread(self._probe_sync, url, timeout) + + def _probe_sync(self, url: Any, timeout: float) -> dict[str, Any]: + with self._lock: + if url is not None and str(url).strip(): + base_url = normalize_remote_url(url) + else: + base_url = self.base_url + if not base_url: + self._status = self._make_status( + "disconnected", message="Remote Hermes is not configured" + ) + return dict(self._status) + is_current = base_url == self.base_url + jar = self.cookie_jar if is_current else CookieJar() + source = self.source if is_current else "candidate" + status = self._probe_base( + base_url, + jar, + configured=is_current, + source=source, + timeout=timeout, + ) + if not is_current: + return status + with self._lock: + if self.base_url != base_url or self.cookie_jar is not jar: + # A sign-in or sign-out replaced this session while it was + # being probed; the replacement's status is authoritative. + return dict(self._status) + if status["state"] == "expired" and self._jar_cookies(jar): + self.cookie_jar = CookieJar() + if self.source == "persisted": + self._save() + status["hasSessionCredential"] = False + self._status = status + return dict(status) + + async def login( + self, url: Any, password: Any, timeout: float = 15.0 + ) -> dict[str, Any]: + if not isinstance(password, str) or not password: + raise RpcFault(-32602, "Password is required") + if len(password.encode("utf-8")) > 65536: + raise RpcFault(-32602, "Password is too large") + return await asyncio.to_thread(self._login_sync, url, password, timeout) + + def _login_sync( + self, url: Any, password: str, timeout: float + ) -> dict[str, Any]: + # The sign-in uses its own jar, so the requests need no shared state; + # only committing the resulting session below takes the lock. + base_url = normalize_remote_url(url) + jar = CookieJar() + try: + response = self._request( + base_url, + jar, + "POST", + "/api/auth/login", + {"password": password}, + timeout, + ) + except _RemoteAuthRequired as exc: + with self._lock: + status = self._make_status( + "expired", + configured=base_url == self.base_url, + url=base_url, + reachable=True, + auth_enabled=True, + message="Remote Hermes rejected the sign-in", + status_code=exc.status_code, + ) + if base_url == self.base_url or not self.base_url: + self._status = status + raise RemoteLoginFault("Remote Hermes rejected the sign-in", status) from None + except _RemoteRedirectBlocked: + status = self._make_status( + "error", + configured=base_url == self.base_url, + url=base_url, + reachable=True, + message="Remote Hermes attempted a cross-origin redirect", + error_kind="redirect", + ) + raise RemoteLoginFault(status["message"], status, -32041) from None + except _RemoteTransportError: + status = self._make_status( + "error", + configured=base_url == self.base_url, + url=base_url, + reachable=False, + message="Remote Hermes is unreachable", + error_kind="offline", + ) + raise RemoteLoginFault(status["message"], status, -32042) from None + if response["status"] == 429: + status = self._make_status( + "error", + configured=base_url == self.base_url, + url=base_url, + reachable=True, + message="Remote Hermes temporarily rate-limited sign-in", + error_kind="rate-limit", + ) + raise RemoteLoginFault(status["message"], status, -32044) + if not 200 <= response["status"] < 300: + status = self._make_status( + "error", + configured=base_url == self.base_url, + url=base_url, + reachable=True, + message=f"Remote Hermes returned HTTP {response['status']}", + error_kind="http", + status_code=response["status"], + ) + raise RemoteLoginFault(status["message"], status, -32041) + value = self._json_response(response) + if value.get("ok") is not True: + status = self._make_status( + "expired", + configured=base_url == self.base_url, + url=base_url, + reachable=True, + auth_enabled=True, + message="Remote Hermes rejected the sign-in", + ) + raise RemoteLoginFault(status["message"], status) + status = self._probe_base( + base_url, + jar, + configured=True, + source="persisted", + timeout=timeout, + ) + if status["state"] != "connected": + message = ( + "Remote Hermes rejected the sign-in" + if status["state"] == "expired" + else status["message"] + ) + raise RemoteLoginFault(message, status) + with self._lock: + self.base_url = base_url + self.cookie_jar = jar + self.source = "persisted" + status["source"] = self.source + status["hasSessionCredential"] = bool(self._jar_cookies(jar)) + self._status = status + self._save() + return dict(status) + + async def logout(self, timeout: float = 10.0) -> dict[str, Any]: + return await asyncio.to_thread(self._logout_sync, timeout) + + def _logout_sync(self, timeout: float) -> dict[str, Any]: + with self._lock: + base_url, jar = self.base_url, self.cookie_jar + remote_logout = False + if base_url: + try: + response = self._request( + base_url, + jar, + "POST", + "/api/auth/logout", + {}, + timeout, + ) + remote_logout = 200 <= response["status"] < 300 + except ( + _RemoteAuthRequired, + _RemoteRedirectBlocked, + _RemoteTransportError, + RpcFault, + ): + # Local credential removal is authoritative even when the + # remote session has already expired or is unreachable. + remote_logout = False + with self._lock: + self.cookie_jar = CookieJar() + self._delete_file() + self.base_url = self.environment_url + self.source = "environment" if self.environment_url else "none" + self._status = self._make_status( + "disconnected", + configured=bool(self.base_url), + url=self.base_url, + message=( + "Remote Hermes signed out" + if remote_logout + else "Saved remote session was removed" + ), + ) + result = dict(self._status) + result["remoteLogout"] = remote_logout + return result + + async def request_json( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + timeout: float = 30.0, + ) -> dict[str, Any]: + """Reusable authenticated request primitive for the remote adapter.""" + + return await asyncio.to_thread( + self._request_json_sync, method, path, payload, timeout + ) + + async def upload_file( + self, + path: str, + fields: dict[str, str], + filename: str, + data: bytes, + mime_type: str, + timeout: float = 60.0, + ) -> dict[str, Any]: + """Upload one bounded file with the saved WebUI session cookie.""" + + return await asyncio.to_thread( + self._upload_file_sync, + path, + fields, + filename, + data, + mime_type, + timeout, + ) + + async def probe_multipart_route( + self, path: str, timeout: float = 15.0 + ) -> int: + """Return a multipart route's status after a fully consumed empty form.""" + + return await asyncio.to_thread( + self._probe_multipart_route_sync, path, timeout + ) + + def _probe_multipart_route_sync(self, path: str, timeout: float) -> int: + boundary = "----HermesMenubarProbe" + uuid.uuid4().hex + encoded = ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="session_id"\r\n\r\n' + "\r\n" + f"--{boundary}--\r\n" + ).encode("ascii") + base_url, jar = self._current_session() + try: + response = self._request( + base_url, + jar, + "POST", + path, + None, + timeout, + encoded_body=encoded, + content_type=f"multipart/form-data; boundary={boundary}", + ) + except _RemoteAuthRequired as exc: + raise self._session_fault(base_url, jar, exc.status_code) from None + except _RemoteRedirectBlocked: + raise RpcFault( + -32041, "Remote Hermes attempted a cross-origin redirect" + ) from None + except _RemoteTransportError: + raise RpcFault(-32042, "Remote Hermes is unreachable") from None + if response["status"] == 401: + raise self._session_fault(base_url, jar, 401) + return int(response["status"]) + + def _upload_file_sync( + self, + path: str, + fields: dict[str, str], + filename: str, + data: bytes, + mime_type: str, + timeout: float, + ) -> dict[str, Any]: + if len(data) > MAX_REMOTE_ATTACHMENT_BYTES: + raise RpcFault(-32602, "Attachment is larger than 20 MiB") + safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", Path(filename).name)[:200] + if not safe_name or safe_name.strip(".") == "": + safe_name = "attachment" + boundary = "----HermesMenubar" + uuid.uuid4().hex + chunks: list[bytes] = [] + for name, value in fields.items(): + safe_field = re.sub(r"[^A-Za-z0-9_-]", "", str(name))[:80] + if not safe_field: + continue + chunks.extend([ + f"--{boundary}\r\n".encode("ascii"), + ( + f'Content-Disposition: form-data; name="{safe_field}"\r\n\r\n' + ).encode("ascii"), + str(value).encode("utf-8"), + b"\r\n", + ]) + chunks.extend([ + f"--{boundary}\r\n".encode("ascii"), + ( + 'Content-Disposition: form-data; name="file"; ' + f'filename="{safe_name}"\r\n' + ).encode("ascii"), + f"Content-Type: {mime_type or 'application/octet-stream'}\r\n\r\n".encode( + "ascii", errors="replace" + ), + data, + b"\r\n", + f"--{boundary}--\r\n".encode("ascii"), + ]) + encoded = b"".join(chunks) + + base_url, jar = self._current_session() + try: + response = self._request( + base_url, + jar, + "POST", + path, + None, + timeout, + encoded_body=encoded, + content_type=f"multipart/form-data; boundary={boundary}", + ) + except _RemoteAuthRequired as exc: + raise self._session_fault(base_url, jar, exc.status_code) from None + except _RemoteRedirectBlocked: + raise RpcFault( + -32041, "Remote Hermes attempted a cross-origin redirect" + ) from None + except _RemoteTransportError: + raise RpcFault(-32042, "Remote Hermes is unreachable") from None + if response["status"] == 401: + raise self._session_fault(base_url, jar, 401) + if not 200 <= response["status"] < 300: + raise self._http_error_fault(response) + return self._json_response(response) + + def _request_json_sync( + self, + method: str, + path: str, + payload: dict[str, Any] | None, + timeout: float, + ) -> dict[str, Any]: + base_url, jar = self._current_session() + try: + response = self._request( + base_url, + jar, + method, + path, + payload, + timeout, + ) + except _RemoteAuthRequired as exc: + raise self._session_fault(base_url, jar, exc.status_code) from None + except _RemoteRedirectBlocked: + raise RpcFault( + -32041, "Remote Hermes attempted a cross-origin redirect" + ) from None + except _RemoteTransportError: + if method.upper() == "POST" and path == "/api/chat/start": + raise AmbiguousDelivery("remote prompt.submit") from None + raise RpcFault(-32042, "Remote Hermes is unreachable") from None + if response["status"] == 401: + raise self._session_fault(base_url, jar, 401) + if not 200 <= response["status"] < 300: + raise self._http_error_fault(response) + return self._json_response(response) + + async def probe_contract(self, timeout: float = 10.0) -> dict[str, Any]: + """Read the WebUI's non-streaming SSE capability probe and server tag.""" + + return await asyncio.to_thread(self._probe_contract_sync, timeout) + + def _probe_contract_sync(self, timeout: float) -> dict[str, Any]: + base_url, jar = self._current_session() + try: + response = self._request( + base_url, + jar, + "GET", + "/api/sessions/gateway/stream?probe=1", + None, + timeout, + ) + except _RemoteAuthRequired as exc: + raise self._session_fault(base_url, jar, exc.status_code) from None + except _RemoteRedirectBlocked: + raise RpcFault( + -32041, "Remote Hermes attempted a cross-origin redirect" + ) from None + except _RemoteTransportError: + raise RpcFault(-32042, "Remote Hermes is unreachable") from None + + server = re.sub( + r"[^A-Za-z0-9._/ +()-]", "", str(response["headers"].get("Server", "")) + ).strip()[:128] + try: + value = self._json_response(response) + except RpcFault: + value = {} + session_path = str(value.get("session_stream_path") or "") + if not session_path.startswith("/api/") or "://" in session_path: + session_path = "/api/session/stream" + try: + fallback_poll_ms = int(value.get("fallback_poll_ms") or 30000) + except (TypeError, ValueError): + fallback_poll_ms = 30000 + return { + "checked": True, + "probeStatus": int(response.get("status") or 0), + "server": server, + "gatewaySessions": value.get("ok") is True, + "gatewayWatcher": value.get("watcher_running") is True, + "sessionStream": value.get("session_stream_available") is True, + "sessionStreamPath": session_path, + "fallbackPollMs": max(5000, min(300000, fallback_poll_ms)), + } + + def _current_session(self) -> tuple[str, CookieJar]: + """Snapshot the origin and cookie jar one unlocked request will use.""" + + with self._lock: + if not self.base_url: + raise RpcFault(-32040, "Remote Hermes is not configured") + return self.base_url, self.cookie_jar + + def _session_fault( + self, base_url: str, jar: CookieJar, status_code: int = 401 + ) -> RpcFault: + """Expire the session a challenged request used, if it is still current.""" + + with self._lock: + if self.base_url == base_url and self.cookie_jar is jar: + status = self._expire_session_locked(status_code) + elif self._status.get("state") == "expired": + # A concurrent request already expired this session. + status = dict(self._status) + else: + # A sign-in, sign-out, or origin change finished while this + # request was in flight. Its challenge describes a session that + # is already gone, so it must neither clear the replacement's + # cookies nor report the replacement as expired. + return RpcFault( + -32042, "Remote Hermes session changed during the request" + ) + return RemoteLoginFault(status["message"], status) + + def _expire_session_locked(self, status_code: int = 401) -> dict[str, Any]: + self.cookie_jar = CookieJar() + if self.source == "persisted": + self._save() + self._status = self._make_status( + "expired", + configured=bool(self.base_url), + url=self.base_url, + reachable=True, + auth_enabled=True, + message="Remote Hermes authentication is required", + status_code=status_code, + ) + return dict(self._status) + + def open_sse( + self, + path: str, + *, + last_event_id: str = "", + timeout: float = 45.0, + ) -> Any: + """Open one authenticated, same-origin WebUI SSE response. + + This synchronous primitive is intended to be called through + ``asyncio.to_thread``. The caller owns and must close the returned + response. Cookie values and redirect destinations never leave the + bridge process. + """ + + base_url, jar = self._current_session() + if not isinstance(path, str) or not path.startswith("/") or "://" in path: + raise RpcFault(-32602, "Remote Hermes API path is invalid") + headers = { + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + "User-Agent": "cybexos-hermes-menubar-bridge/1", + } + if last_event_id: + headers["Last-Event-ID"] = str(last_event_id)[:1024] + redirect_handler = _SameOriginRedirectHandler(_remote_origin(base_url)) + opener = build_opener(redirect_handler, HTTPCookieProcessor(jar)) + request = Request(f"{base_url}{path}", headers=headers, method="GET") + try: + response = opener.open(request, timeout=timeout) + except _RemoteAuthRequired as exc: + raise self._session_fault(base_url, jar, exc.status_code) from None + except _RemoteRedirectBlocked: + raise RpcFault( + -32041, "Remote Hermes attempted a cross-origin redirect" + ) from None + except HTTPError as exc: + status_code = int(exc.code) + with suppress(Exception): + exc.close() + if status_code == 401: + raise self._session_fault(base_url, jar, status_code) from None + raise RpcFault( + -32041, f"Remote Hermes returned HTTP {status_code}" + ) from None + except (URLError, TimeoutError, OSError): + raise RpcFault(-32042, "Remote Hermes stream is unreachable") from None + + status_code = int(getattr(response, "status", 0) or 0) + content_type = str(response.headers.get("Content-Type", "")).lower() + final_url = str(response.geturl() or "") + if _is_login_url(final_url) or "text/html" in content_type: + with suppress(Exception): + response.close() + raise self._session_fault(base_url, jar, status_code or 302) + if status_code != 200 or "text/event-stream" not in content_type: + with suppress(Exception): + response.close() + raise RpcFault( + -32041, "Remote Hermes returned an invalid event stream" + ) + return response + + def authenticated_headers(self, path: str = "/") -> dict[str, str]: + """Return an origin-scoped Cookie header for an internal SSE adapter. + + The returned value is a credential and must never be sent downstream or + logged. Accepting only an absolute-path reference prevents callers from + accidentally forwarding it to another origin. + """ + + with self._lock: + if not self.base_url: + raise RpcFault(-32040, "Remote Hermes is not configured") + if not isinstance(path, str) or not path.startswith("/") or "://" in path: + raise RpcFault(-32602, "Remote Hermes API path is invalid") + request = Request(f"{self.base_url}{path}") + self.cookie_jar.add_cookie_header(request) + cookie = request.get_header("Cookie") + return {"Cookie": cookie} if cookie else {} diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/gateway.py b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/gateway.py new file mode 100644 index 00000000..80f427cb --- /dev/null +++ b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/gateway.py @@ -0,0 +1,442 @@ +"""Bounded downstream delivery and reconnecting local upstream transport.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from dataclasses import dataclass, field +import json +import os +import random +from typing import Any, Awaitable, Callable +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlparse, urlunparse +from urllib.request import ( + Request, + urlopen, +) + +import websockets +from websockets.asyncio.client import ClientConnection +from websockets.asyncio.server import ServerConnection +from websockets.exceptions import ConnectionClosed + + +from .protocol import ( + LOG, + MAX_UPSTREAM_MESSAGE, + MAX_PROVIDER_RESPONSE, + MAX_CLIENT_BACKLOG, + TOKEN_PATTERN, + RpcFault, + UpstreamUnavailable, + AmbiguousDelivery, + json_frame, +) + +@dataclass(eq=False) +class LocalClient: + """One loopback shell connection with its own bounded outbound queue. + + Producers never await the socket: a dedicated writer task drains the + queue, so one stalled client cannot block the upstream reader (and with + it the gateway heartbeat) or delay delivery to other clients. A client + that falls MAX_CLIENT_BACKLOG frames behind is disconnected; the shell + reconnects and reconciles through its normal hello/list/history path. + """ + + websocket: ServerConnection + tasks: set[asyncio.Task[Any]] = field(default_factory=set) + backlog: int = MAX_CLIENT_BACKLOG + closed: bool = False + queue: asyncio.Queue[str] = field(init=False) + writer: asyncio.Task[Any] | None = field(default=None, init=False) + + def __post_init__(self) -> None: + self.queue = asyncio.Queue(maxsize=max(1, self.backlog)) + + def start(self) -> None: + if self.writer is None: + self.writer = asyncio.create_task( + self._write(), name="hermes-local-writer" + ) + + async def _write(self) -> None: + try: + while True: + text = await self.queue.get() + await self.websocket.send(text) + except ConnectionClosed: + pass + except asyncio.CancelledError: + raise + except Exception as exc: + LOG.debug("local client write failed: %s", exc) + finally: + self.closed = True + + def enqueue_text(self, text: str) -> bool: + if self.closed: + return False + try: + self.queue.put_nowait(text) + except asyncio.QueueFull: + LOG.warning( + "local Hermes client fell %d frames behind; disconnecting it", + self.queue.maxsize, + ) + self.abort("client too slow") + return False + return True + + def abort(self, reason: str) -> None: + if self.closed: + return + self.closed = True + if self.writer is not None: + self.writer.cancel() + closer = asyncio.create_task( + self.websocket.close(code=1013, reason=reason), + name="hermes-local-close", + ) + self.tasks.add(closer) + closer.add_done_callback(self._closed) + + def _closed(self, task: asyncio.Task[Any]) -> None: + self.tasks.discard(task) + if not task.cancelled(): + task.exception() + + async def stop(self) -> None: + self.closed = True + if self.writer is not None: + self.writer.cancel() + with suppress(asyncio.CancelledError, Exception): + await self.writer + + async def send(self, frame: dict[str, Any]) -> None: + self.enqueue_text(json_frame(frame)) + + +@dataclass +class PendingUpstream: + method: str + future: asyncio.Future[Any] + written: bool = False + + +class HermesGateway: + """Authenticated, reconnecting JSON-RPC client for ``hermes serve``.""" + + def __init__( + self, + base_url: str, + on_event: Callable[[dict[str, Any]], Awaitable[None]], + on_state: Callable[[str, str], Awaitable[None]], + on_ready: Callable[[str], Awaitable[None]], + ): + self.base_url = base_url.rstrip("/") + self.on_event = on_event + self.on_state = on_state + self.on_ready = on_ready + self.websocket: ClientConnection | None = None + self.connected = asyncio.Event() + self.ready_epoch = "" + self._pending: dict[str, PendingUpstream] = {} + self._next_id = 0 + self._send_lock = asyncio.Lock() + self._stop = asyncio.Event() + self._runner: asyncio.Task[Any] | None = None + + def start(self) -> None: + if self._runner is None: + self._runner = asyncio.create_task(self._run(), name="hermes-upstream") + + async def stop(self) -> None: + self._stop.set() + websocket = self.websocket + if websocket is not None: + with suppress(Exception): + await websocket.close(code=1001, reason="bridge stopping") + if self._runner is not None: + self._runner.cancel() + with suppress(asyncio.CancelledError): + await self._runner + + async def request( + self, method: str, params: dict[str, Any] | None = None, timeout: float = 30.0 + ) -> Any: + if not self.connected.is_set() or self.websocket is None: + raise UpstreamUnavailable() + self._next_id += 1 + request_id = f"menubar-{self._next_id}" + future = asyncio.get_running_loop().create_future() + pending = PendingUpstream(method=method, future=future) + self._pending[request_id] = pending + frame = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params or {}, + } + try: + async with self._send_lock: + websocket = self.websocket + if websocket is None: + raise UpstreamUnavailable() + await websocket.send(json_frame(frame)) + pending.written = True + return await asyncio.wait_for(future, timeout=timeout) + except asyncio.TimeoutError as exc: + self._pending.pop(request_id, None) + if method == "prompt.submit" and pending.written: + raise AmbiguousDelivery(method) from exc + raise RpcFault( + -32012, + f"Hermes did not answer {method} within {int(timeout)} seconds", + {"method": method}, + ) from exc + except ConnectionClosed as exc: + self._pending.pop(request_id, None) + if pending.written: + raise AmbiguousDelivery(method) from exc + raise UpstreamUnavailable() from exc + finally: + self._pending.pop(request_id, None) + + async def api_request( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + timeout: float = 20.0, + ) -> Any: + """Call an authenticated Hermes dashboard API without exposing its token. + + Provider setup is a dashboard REST API rather than a gateway RPC. The + bridge obtains the same private session token it already uses for the + upstream WebSocket and keeps both that token and submitted credentials + out of downstream responses and logs. + """ + return await asyncio.to_thread( + self._api_request_sync, method, path, payload, timeout + ) + + def _api_request_sync( + self, + method: str, + path: str, + payload: dict[str, Any] | None, + timeout: float, + ) -> Any: + if not path.startswith("/api/") or "://" in path: + raise RpcFault(-32602, "invalid Hermes API path") + try: + token = self._fetch_token() + except Exception as exc: + raise UpstreamUnavailable("Hermes provider API is unavailable") from exc + body = ( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) + if payload is not None + else None + ) + headers = { + "Accept": "application/json", + "User-Agent": "cybexos-hermes-menubar-bridge/1", + "X-Hermes-Session-Token": token, + } + if body is not None: + headers["Content-Type"] = "application/json" + request = Request( + f"{self.base_url}{path}", + data=body, + method=method.upper(), + headers=headers, + ) + try: + with urlopen(request, timeout=timeout) as response: + raw = response.read(MAX_PROVIDER_RESPONSE + 1) + except HTTPError as exc: + raw = exc.read(MAX_PROVIDER_RESPONSE + 1) + message = self._api_error_message(raw) + raise RpcFault( + -32030, + message or f"Hermes provider API returned HTTP {exc.code}", + ) from exc + except (URLError, TimeoutError, OSError) as exc: + raise UpstreamUnavailable("Hermes provider API is unavailable") from exc + if len(raw) > MAX_PROVIDER_RESPONSE: + raise RpcFault(-32030, "Hermes provider API response is too large") + if not raw: + return {} + try: + return json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError) as exc: + raise RpcFault(-32030, "Hermes provider API returned invalid JSON") from exc + + @staticmethod + def _api_error_message(raw: bytes) -> str: + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError): + return "" + if not isinstance(value, dict): + return "" + detail = value.get("detail") or value.get("message") or value.get("error") + return str(detail)[:500] if isinstance(detail, (str, int, float)) else "" + + async def _run(self) -> None: + backoff = 0.5 + while not self._stop.is_set(): + await self.on_state("connecting", "Connecting to Hermes…") + receiver: asyncio.Task[Any] | None = None + heartbeat: asyncio.Task[Any] | None = None + try: + token = await asyncio.to_thread(self._fetch_token) + websocket_url = self._websocket_url(token) + async with websockets.connect( + websocket_url, + open_timeout=15, + close_timeout=5, + max_size=MAX_UPSTREAM_MESSAGE, + ping_interval=20, + ping_timeout=45, + ) as websocket: + self.websocket = websocket + receiver = asyncio.create_task( + self._receive(websocket), name="hermes-upstream-receive" + ) + await asyncio.wait_for(self.connected.wait(), timeout=30) + backoff = 0.5 + await self.on_state("connected", "Hermes connected") + ready_task = asyncio.create_task( + self.on_ready(self.ready_epoch), name="hermes-reconcile" + ) + ready_task.add_done_callback(self._log_background_failure) + heartbeat = asyncio.create_task( + self._heartbeat(websocket), name="hermes-upstream-heartbeat" + ) + await receiver + except asyncio.CancelledError: + raise + except Exception as exc: + if not self._stop.is_set(): + LOG.warning("Hermes connection unavailable: %s", exc) + finally: + self.connected.clear() + self.websocket = None + for task in (receiver, heartbeat): + if task is not None and not task.done(): + task.cancel() + self._reject_pending() + + if self._stop.is_set(): + break + await self.on_state("reconnecting", "Reconnecting to Hermes…") + try: + await asyncio.wait_for( + self._stop.wait(), timeout=backoff + random.random() * 0.25 + ) + except asyncio.TimeoutError: + pass + backoff = min(backoff * 2, 15.0) + + @staticmethod + def _log_background_failure(task: asyncio.Task[Any]) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + LOG.error("Hermes reconciliation failed: %s", exc) + + def _fetch_token(self) -> str: + configured = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN", "").strip() + if configured: + return configured + request = Request( + f"{self.base_url}/", + headers={"User-Agent": "cybexos-hermes-menubar-bridge/1"}, + ) + with urlopen(request, timeout=10) as response: + body = response.read(1024 * 1024).decode("utf-8", errors="replace") + match = TOKEN_PATTERN.search(body) + if not match: + raise RuntimeError("Hermes headless token was not present at the root URL") + token = json.loads(match.group(1)) + if not isinstance(token, str) or not token: + raise RuntimeError("Hermes returned an invalid headless token") + return token + + def _websocket_url(self, token: str) -> str: + parsed = urlparse(self.base_url) + if parsed.scheme not in {"http", "https"}: + raise RuntimeError("Hermes upstream must use http:// or https://") + scheme = "wss" if parsed.scheme == "https" else "ws" + path = f"{parsed.path.rstrip('/')}/api/ws" + return urlunparse( + (scheme, parsed.netloc, path, "", f"token={quote(token, safe='')}", "") + ) + + async def _receive(self, websocket: ClientConnection) -> None: + async for raw in websocket: + if not isinstance(raw, str): + continue + try: + frame = json.loads(raw) + except (json.JSONDecodeError, TypeError): + LOG.warning("Hermes sent malformed JSON") + continue + if not isinstance(frame, dict): + continue + request_id = frame.get("id") + if request_id is not None: + pending = self._pending.get(str(request_id)) + if pending is None or pending.future.done(): + continue + error = frame.get("error") + if isinstance(error, dict): + pending.future.set_exception( + RpcFault( + int(error.get("code") or -32000), + str(error.get("message") or "Hermes RPC failed"), + error.get("data"), + ) + ) + else: + pending.future.set_result(frame.get("result")) + continue + if frame.get("method") != "event" or not isinstance( + frame.get("params"), dict + ): + continue + event = frame["params"] + if event.get("type") == "gateway.ready": + payload = event.get("payload") + self.ready_epoch = ( + str(payload.get("replay_epoch") or "") + if isinstance(payload, dict) + else "" + ) + self.connected.set() + await self.on_event(event) + + async def _heartbeat(self, websocket: ClientConnection) -> None: + while websocket is self.websocket and not self._stop.is_set(): + await asyncio.sleep(15) + try: + await self.request("gateway.ping", {}, timeout=10) + except RpcFault: + with suppress(Exception): + await websocket.close(code=1011, reason="heartbeat failed") + return + + def _reject_pending(self) -> None: + for pending in list(self._pending.values()): + if pending.future.done(): + continue + if pending.written: + pending.future.set_exception(AmbiguousDelivery(pending.method)) + else: + pending.future.set_exception(UpstreamUnavailable()) diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py new file mode 100644 index 00000000..a329b0a8 --- /dev/null +++ b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py @@ -0,0 +1,122 @@ +"""Shared wire errors, limits and serialization; no bridge state.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import ipaddress +import json +import logging +import re +from typing import Any + + +LOG = logging.getLogger("hermes-menubar-bridge") +BRIDGE_VERSION = 1 +MAX_DOWNSTREAM_MESSAGE = 2 * 1024 * 1024 +MAX_UPSTREAM_MESSAGE = 384 * 1024 * 1024 +MAX_PROVIDER_RESPONSE = 4 * 1024 * 1024 +MAX_REMOTE_AUTH_RESPONSE = 2 * 1024 * 1024 +MAX_REMOTE_SSE_EVENT = 4 * 1024 * 1024 +MAX_REMOTE_STREAM_EVENT = 4 * 1024 * 1024 +MAX_REMOTE_ATTACHMENT_BYTES = 20 * 1024 * 1024 +MAX_REMOTE_ATTACHMENTS = 20 +REMOTE_HISTORY_PAGE = 80 +REMOTE_HISTORY_MAX_MESSAGES = 250 +REMOTE_HISTORY_MAX_TOOLS = 250 +MAX_REMOTE_TOOL_DETAIL = 4096 +MAX_REMOTE_REASONING = 12000 +DEFAULT_UPSTREAM = "http://127.0.0.1:9119" +DEFAULT_LISTEN = "127.0.0.1" +DEFAULT_PORT = 9120 +DEFAULT_PATH = "/ws" +# Live status churn (thinking/tool progress) is coalesced into one registry +# write per window; explicit saves and shutdown still write immediately. +REGISTRY_SAVE_DELAY = 1.0 +# Frames buffered per local client. A shell that stops reading falls behind +# by this many frames and is disconnected rather than stalling the event +# fan-out that also carries the upstream heartbeat. +MAX_CLIENT_BACKLOG = 2048 +TOKEN_PATTERN = re.compile( + r"window\.__HERMES_SESSION_TOKEN__\s*=\s*(\"(?:\\.|[^\"\\])*\")" +) +CONVERSATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$") +REMOTE_AUTH_VERSION = 1 +# Remote observer streams (the session list and the selected session) are +# long-lived. Only one that stayed open this long proves the server healthy +# and resets reconnect backoff; a server that accepts and promptly closes, or +# fails, is retried with jittered exponential delays between the floor and +# the cap instead of at a fixed sub-second rate. +REMOTE_OBSERVER_HEALTHY_SECONDS = 60.0 +REMOTE_OBSERVER_RETRY_FLOOR = 3.0 +REMOTE_OBSERVER_RETRY_CAP = 120.0 +# Session-list invalidations arrive in bursts. They share one in-flight list +# refresh plus at most one trailing refresh, started at least this far apart. +REMOTE_REFRESH_SPACING = 1.0 + + +class RpcFault(Exception): + """A JSON-RPC error safe to return to the local client.""" + + def __init__(self, code: int, message: str, data: Any = None): + super().__init__(message) + self.code = code + self.message = message + self.data = data + + +class UpstreamUnavailable(RpcFault): + def __init__(self, message: str = "Hermes is offline"): + super().__init__(-32010, message) + + +class AmbiguousDelivery(RpcFault): + """The socket dropped after a write, so the server may have accepted it.""" + + def __init__(self, method: str): + super().__init__( + -32011, + f"Hermes disconnected while {method} was in flight; its outcome is " + "unknown and the bridge did not retry it", + {"method": method, "deliveryUnknown": True, "replayed": False}, + ) + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace( + "+00:00", "Z" + ) + + +def json_frame(frame: dict[str, Any]) -> str: + return json.dumps(frame, ensure_ascii=False, separators=(",", ":")) + + +def rpc_result(request_id: Any, result: Any) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def rpc_error(request_id: Any, fault: RpcFault) -> dict[str, Any]: + error: dict[str, Any] = {"code": fault.code, "message": fault.message} + if fault.data is not None: + error["data"] = fault.data + return {"jsonrpc": "2.0", "id": request_id, "error": error} + + +def event_frame(event_type: str, payload: dict[str, Any]) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "method": "event", + "params": {"type": event_type, "payload": payload}, + } + + +def slugify(name: str) -> str: + value = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") + return (value or "conversation")[:48] + + +def is_loopback(host: str) -> bool: + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return host.lower() == "localhost" diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/registry.py b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/registry.py new file mode 100644 index 00000000..6f678649 --- /dev/null +++ b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/registry.py @@ -0,0 +1,182 @@ +"""Atomic, coalesced conversation metadata persistence.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +import json +import os +from pathlib import Path +from typing import Any + + +from .protocol import ( + LOG, + BRIDGE_VERSION, + REGISTRY_SAVE_DELAY, + CONVERSATION_ID_PATTERN, + utc_now, +) + +def default_conversations() -> list[dict[str, Any]]: + # New chat is a client-side virtual selection, not a persisted session. + # Historical rows are hydrated from the WebUI's native /api/sessions list. + return [] + + +class ConversationRegistry: + """Small atomic JSON registry; prompts and Hermes credentials never enter it.""" + + def __init__(self, path: Path): + self.path = path + self.conversations: dict[str, dict[str, Any]] = {} + self.selected_conversation_id = "" + self._save_handle: asyncio.TimerHandle | None = None + self._load() + + def _load(self) -> None: + document: dict[str, Any] | None = None + try: + document = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + pass + except (OSError, json.JSONDecodeError, TypeError) as exc: + LOG.error("could not load conversation registry %s: %s", self.path, exc) + + rows = document.get("conversations") if isinstance(document, dict) else None + if isinstance(rows, list): + for row in rows: + conversation = self._coerce_conversation(row) + if conversation is not None and conversation["id"] not in self.conversations: + self.conversations[conversation["id"]] = conversation + + # Selection intentionally never survives a bridge restart. The widget + # always opens on a fresh chat while the list remains available. + self.selected_conversation_id = "" + + @staticmethod + def _coerce_conversation(row: Any) -> dict[str, Any] | None: + if not isinstance(row, dict): + return None + conversation_id = str( + row.get("session_id") or row.get("sessionId") or row.get("id") or "" + ).strip() + title = str(row.get("title") or row.get("name") or "Untitled chat").strip() + if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id): + return None + status = str(row.get("status") or "idle") + if status not in { + "idle", + "working", + "waiting", + "done", + "error", + "offline", + "reconnecting", + }: + status = "idle" + stored_session_id = str( + row.get("stored_session_id") or row.get("storedSessionId") or "" + )[:256] + remote_origin = str( + row.get("remote_origin") or row.get("remoteOrigin") or "" + )[:2048] + remote_session_id = str( + row.get("remote_session_id") or row.get("remoteSessionId") or "" + )[:256] + return { + "id": conversation_id, + "name": title[:160], + "title": title[:160] or "Untitled chat", + "brief": str(row.get("brief") or "")[:4000], + "profile": str(row.get("profile") or "")[:128], + "cwd": str(row.get("cwd") or "")[:4096], + "stored_session_id": stored_session_id, + "remote_origin": remote_origin, + "remote_session_id": remote_session_id, + # Missing on old registries: be conservative and assume a durable + # id may contain user history. Only known-empty lazy sessions are + # safe to recreate after a session-not-found resume. + "has_messages": bool( + row.get("has_messages", bool(stored_session_id or remote_session_id)) + ), + "status": status, + "status_text": str(row.get("status_text") or "Ready")[:240], + "unread": bool(row.get("unread", False)), + "updated_at": str(row.get("updated_at") or utc_now()), + "created_at": str(row.get("created_at") or ""), + "model": str(row.get("model") or "")[:256], + "model_provider": str( + row.get("model_provider") or row.get("modelProvider") or "" + )[:128], + "source": str( + row.get("source") + or row.get("source_label") + or row.get("sourceLabel") + or row.get("session_source") + or row.get("sessionSource") + or "" + )[:128], + "read_only": bool(row.get("read_only", row.get("readOnly", False))), + "message_count": ConversationRegistry._message_count( + row.get("message_count") + ), + } + + @staticmethod + def _message_count(value: Any) -> int: + try: + return max(0, int(value or 0)) + except (TypeError, ValueError, OverflowError): + return 0 + + def save_later(self, delay: float = REGISTRY_SAVE_DELAY) -> None: + """Coalesce frequent status writes into one atomic save per window.""" + if self._save_handle is not None: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + self.save() + return + self._save_handle = loop.call_later(delay, self._deferred_save) + + def _deferred_save(self) -> None: + self._save_handle = None + try: + self.save() + except OSError as exc: + LOG.error("could not save conversation registry %s: %s", self.path, exc) + + def flush(self) -> None: + """Write a pending coalesced save now (used on shutdown).""" + if self._save_handle is not None: + self.save() + + def save(self) -> None: + if self._save_handle is not None: + self._save_handle.cancel() + self._save_handle = None + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + with suppress(OSError): + os.chmod(self.path.parent, 0o700) + document = { + "version": BRIDGE_VERSION, + "selected_conversation_id": self.selected_conversation_id, + "conversations": list(self.conversations.values()), + } + temporary = self.path.with_name(f".{self.path.name}.tmp.{os.getpid()}") + descriptor = os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(document, stream, ensure_ascii=False, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, self.path) + os.chmod(self.path, 0o600) + finally: + with suppress(FileNotFoundError): + temporary.unlink() diff --git a/roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py b/roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py index 3f31fa27..885b390c 100644 --- a/roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py +++ b/roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py @@ -22,139 +22,88 @@ import argparse import asyncio from contextlib import suppress -from dataclasses import dataclass, field from datetime import datetime, timezone import hashlib -from http.cookiejar import Cookie, CookieJar -import ipaddress import json import logging import mimetypes import os from pathlib import Path import random -import re import signal import stat import sys import threading import time from typing import Any, Awaitable, Callable -from urllib.error import HTTPError, URLError -from urllib.parse import quote, urljoin, urlparse, urlunparse -from urllib.request import ( - HTTPRedirectHandler, - HTTPCookieProcessor, - Request, - build_opener, - urlopen, -) +from urllib.parse import quote, urlparse import uuid import websockets -from websockets.asyncio.client import ClientConnection from websockets.asyncio.server import ServerConnection from websockets.exceptions import ConnectionClosed -LOG = logging.getLogger("hermes-menubar-bridge") -BRIDGE_VERSION = 1 -MAX_DOWNSTREAM_MESSAGE = 2 * 1024 * 1024 -MAX_UPSTREAM_MESSAGE = 384 * 1024 * 1024 -MAX_PROVIDER_RESPONSE = 4 * 1024 * 1024 -MAX_REMOTE_AUTH_RESPONSE = 2 * 1024 * 1024 -MAX_REMOTE_SSE_EVENT = 4 * 1024 * 1024 -MAX_REMOTE_STREAM_EVENT = 4 * 1024 * 1024 -MAX_REMOTE_ATTACHMENT_BYTES = 20 * 1024 * 1024 -MAX_REMOTE_ATTACHMENTS = 20 -REMOTE_HISTORY_PAGE = 80 -REMOTE_HISTORY_MAX_MESSAGES = 250 -REMOTE_HISTORY_MAX_TOOLS = 250 -MAX_REMOTE_TOOL_DETAIL = 4096 -MAX_REMOTE_REASONING = 12000 -DEFAULT_UPSTREAM = "http://127.0.0.1:9119" -DEFAULT_LISTEN = "127.0.0.1" -DEFAULT_PORT = 9120 -DEFAULT_PATH = "/ws" -# Live status churn (thinking/tool progress) is coalesced into one registry -# write per window; explicit saves and shutdown still write immediately. -REGISTRY_SAVE_DELAY = 1.0 -# Frames buffered per local client. A shell that stops reading falls behind -# by this many frames and is disconnected rather than stalling the event -# fan-out that also carries the upstream heartbeat. -MAX_CLIENT_BACKLOG = 2048 -TOKEN_PATTERN = re.compile( - r"window\.__HERMES_SESSION_TOKEN__\s*=\s*(\"(?:\\.|[^\"\\])*\")" +from cybex_hermes.protocol import ( + LOG as LOG, + BRIDGE_VERSION as BRIDGE_VERSION, + MAX_DOWNSTREAM_MESSAGE as MAX_DOWNSTREAM_MESSAGE, + MAX_UPSTREAM_MESSAGE as MAX_UPSTREAM_MESSAGE, + MAX_PROVIDER_RESPONSE as MAX_PROVIDER_RESPONSE, + MAX_REMOTE_AUTH_RESPONSE as MAX_REMOTE_AUTH_RESPONSE, + MAX_REMOTE_SSE_EVENT as MAX_REMOTE_SSE_EVENT, + MAX_REMOTE_STREAM_EVENT as MAX_REMOTE_STREAM_EVENT, + MAX_REMOTE_ATTACHMENT_BYTES as MAX_REMOTE_ATTACHMENT_BYTES, + MAX_REMOTE_ATTACHMENTS as MAX_REMOTE_ATTACHMENTS, + REMOTE_HISTORY_PAGE as REMOTE_HISTORY_PAGE, + REMOTE_HISTORY_MAX_MESSAGES as REMOTE_HISTORY_MAX_MESSAGES, + REMOTE_HISTORY_MAX_TOOLS as REMOTE_HISTORY_MAX_TOOLS, + MAX_REMOTE_TOOL_DETAIL as MAX_REMOTE_TOOL_DETAIL, + MAX_REMOTE_REASONING as MAX_REMOTE_REASONING, + DEFAULT_UPSTREAM as DEFAULT_UPSTREAM, + DEFAULT_LISTEN as DEFAULT_LISTEN, + DEFAULT_PORT as DEFAULT_PORT, + DEFAULT_PATH as DEFAULT_PATH, + REGISTRY_SAVE_DELAY as REGISTRY_SAVE_DELAY, + MAX_CLIENT_BACKLOG as MAX_CLIENT_BACKLOG, + TOKEN_PATTERN as TOKEN_PATTERN, + CONVERSATION_ID_PATTERN as CONVERSATION_ID_PATTERN, + REMOTE_AUTH_VERSION as REMOTE_AUTH_VERSION, + REMOTE_OBSERVER_HEALTHY_SECONDS as REMOTE_OBSERVER_HEALTHY_SECONDS, + REMOTE_OBSERVER_RETRY_FLOOR as REMOTE_OBSERVER_RETRY_FLOOR, + REMOTE_OBSERVER_RETRY_CAP as REMOTE_OBSERVER_RETRY_CAP, + REMOTE_REFRESH_SPACING as REMOTE_REFRESH_SPACING, + RpcFault as RpcFault, + UpstreamUnavailable as UpstreamUnavailable, + AmbiguousDelivery as AmbiguousDelivery, + utc_now as utc_now, + json_frame as json_frame, + rpc_result as rpc_result, + rpc_error as rpc_error, + event_frame as event_frame, + slugify as slugify, + is_loopback as is_loopback, +) +from cybex_hermes.auth import ( + _RemoteAuthRequired as _RemoteAuthRequired, + _RemoteRedirectBlocked as _RemoteRedirectBlocked, + _RemoteTransportError as _RemoteTransportError, + _remote_origin as _remote_origin, + normalize_remote_url as normalize_remote_url, + _is_login_url as _is_login_url, + _SameOriginRedirectHandler as _SameOriginRedirectHandler, + RemoteLoginFault as RemoteLoginFault, + RemoteWebUIAuth as RemoteWebUIAuth, +) +from cybex_hermes.registry import ( + default_conversations as default_conversations, + ConversationRegistry as ConversationRegistry, +) +from cybex_hermes.gateway import ( + LocalClient as LocalClient, + PendingUpstream as PendingUpstream, + HermesGateway as HermesGateway, ) -CONVERSATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$") -REMOTE_AUTH_VERSION = 1 -# Remote observer streams (the session list and the selected session) are -# long-lived. Only one that stayed open this long proves the server healthy -# and resets reconnect backoff; a server that accepts and promptly closes, or -# fails, is retried with jittered exponential delays between the floor and -# the cap instead of at a fixed sub-second rate. -REMOTE_OBSERVER_HEALTHY_SECONDS = 60.0 -REMOTE_OBSERVER_RETRY_FLOOR = 3.0 -REMOTE_OBSERVER_RETRY_CAP = 120.0 -# Session-list invalidations arrive in bursts. They share one in-flight list -# refresh plus at most one trailing refresh, started at least this far apart. -REMOTE_REFRESH_SPACING = 1.0 - - -class RpcFault(Exception): - """A JSON-RPC error safe to return to the local client.""" - - def __init__(self, code: int, message: str, data: Any = None): - super().__init__(message) - self.code = code - self.message = message - self.data = data - - -class UpstreamUnavailable(RpcFault): - def __init__(self, message: str = "Hermes is offline"): - super().__init__(-32010, message) - - -class AmbiguousDelivery(RpcFault): - """The socket dropped after a write, so the server may have accepted it.""" - - def __init__(self, method: str): - super().__init__( - -32011, - f"Hermes disconnected while {method} was in flight; its outcome is " - "unknown and the bridge did not retry it", - {"method": method, "deliveryUnknown": True, "replayed": False}, - ) - - -def utc_now() -> str: - return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace( - "+00:00", "Z" - ) - - -def json_frame(frame: dict[str, Any]) -> str: - return json.dumps(frame, ensure_ascii=False, separators=(",", ":")) - - -def rpc_result(request_id: Any, result: Any) -> dict[str, Any]: - return {"jsonrpc": "2.0", "id": request_id, "result": result} - - -def rpc_error(request_id: Any, fault: RpcFault) -> dict[str, Any]: - error: dict[str, Any] = {"code": fault.code, "message": fault.message} - if fault.data is not None: - error["data"] = fault.data - return {"jsonrpc": "2.0", "id": request_id, "error": error} - - -def event_frame(event_type: str, payload: dict[str, Any]) -> dict[str, Any]: - return { - "jsonrpc": "2.0", - "method": "event", - "params": {"type": event_type, "payload": payload}, - } def remote_observer_retry_delay(failures: int) -> float: @@ -176,1805 +125,6 @@ def remote_observer_failures(failures: int, connected_at: float | None) -> int: return failures + 1 -def slugify(name: str) -> str: - value = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") - return (value or "conversation")[:48] - - -def is_loopback(host: str) -> bool: - try: - return ipaddress.ip_address(host).is_loopback - except ValueError: - return host.lower() == "localhost" - - -class _RemoteAuthRequired(Exception): - """A remote response is an authentication challenge, not API data.""" - - def __init__(self, status_code: int = 401): - super().__init__("remote authentication required") - self.status_code = status_code - - -class _RemoteRedirectBlocked(Exception): - """A redirect attempted to leave the configured WebUI origin.""" - - -class _RemoteTransportError(Exception): - """The remote WebUI could not be reached.""" - - -def _remote_origin(url: str) -> tuple[str, str, int]: - parsed = urlparse(url) - try: - port = parsed.port - except ValueError as exc: - raise RpcFault(-32602, "Remote Hermes URL has an invalid port") from exc - scheme = parsed.scheme.lower() - hostname = (parsed.hostname or "").lower().rstrip(".") - if not hostname or scheme not in {"http", "https"}: - raise RpcFault(-32602, "Remote Hermes URL must use http:// or https://") - return scheme, hostname, port or (443 if scheme == "https" else 80) - - -def normalize_remote_url(raw: Any) -> str: - """Validate and canonicalize a user-provided Hermes WebUI base URL.""" - - if not isinstance(raw, str) or not raw.strip(): - raise RpcFault(-32602, "Remote Hermes URL is required") - value = raw.strip().rstrip("/") - if len(value) > 2048 or any(ord(character) < 0x20 for character in value): - raise RpcFault(-32602, "Remote Hermes URL is invalid") - if any(character.isspace() or character == "\\" for character in value): - raise RpcFault(-32602, "Remote Hermes URL must not contain whitespace") - try: - parsed = urlparse(value) - hostname = parsed.hostname - port = parsed.port - except ValueError as exc: - raise RpcFault(-32602, "Remote Hermes URL is invalid") from exc - scheme = parsed.scheme.lower() - if ( - scheme not in {"http", "https"} - or not hostname - or parsed.username is not None - or parsed.password is not None - or parsed.params - or parsed.query - or parsed.fragment - ): - raise RpcFault( - -32602, - "Use an http(s) Hermes URL without credentials, query, or fragment", - ) - if scheme == "http" and not is_loopback(hostname): - raise RpcFault( - -32602, - "Remote Hermes URLs must use HTTPS; HTTP is allowed only on loopback", - ) - try: - ascii_hostname = hostname.rstrip(".").encode("idna").decode("ascii").lower() - except UnicodeError as exc: - raise RpcFault(-32602, "Remote Hermes URL has an invalid hostname") from exc - host_for_netloc = ( - f"[{ascii_hostname}]" if ":" in ascii_hostname else ascii_hostname - ) - default_port = 443 if scheme == "https" else 80 - if port is not None and port != default_port: - host_for_netloc = f"{host_for_netloc}:{port}" - path = parsed.path.rstrip("/") - decoded_segments = [ - segment.lower().replace("%2e", ".") for segment in path.split("/") - ] - if any(segment in {".", ".."} for segment in decoded_segments): - raise RpcFault(-32602, "Remote Hermes URL path must not traverse directories") - normalized = urlunparse((scheme, host_for_netloc, path, "", "", "")) - _remote_origin(normalized) - return normalized - - -def _is_login_url(url: str) -> bool: - try: - path = urlparse(url).path.rstrip("/").lower() - except ValueError: - return False - if path.endswith("/api/auth/login") or path.endswith("/api/auth/passkey/login"): - return False - return path == "/login" or path.endswith("/login") - - -class _SameOriginRedirectHandler(HTTPRedirectHandler): - """Follow only redirects that remain on the originally requested origin.""" - - max_redirections = 5 - - def __init__(self, allowed_origin: tuple[str, str, int]): - super().__init__() - self.allowed_origin = allowed_origin - self.redirects: list[str] = [] - - def redirect_request( - self, - request: Request, - file_pointer: Any, - code: int, - message: str, - headers: Any, - new_url: str, - ) -> Request | None: - target = urljoin(request.full_url, new_url) - # Login redirects are a normal expired-session signal. Do not follow - # them, even when a reverse proxy points at a different origin. - if _is_login_url(target): - raise _RemoteAuthRequired(code) - parsed = urlparse(target) - if parsed.username is not None or parsed.password is not None: - raise _RemoteRedirectBlocked() - try: - target_origin = _remote_origin(target) - except RpcFault as exc: - raise _RemoteRedirectBlocked() from exc - if target_origin != self.allowed_origin: - raise _RemoteRedirectBlocked() - self.redirects.append(target) - return super().redirect_request( - request, file_pointer, code, message, headers, target - ) - - -class RemoteLoginFault(RpcFault): - def __init__(self, message: str, status: dict[str, Any], code: int = -32040): - super().__init__(code, message, status) - - -class RemoteWebUIAuth: - """Origin-bound Hermes WebUI cookie session manager. - - The password is used only to build one in-memory login request. The file - contains the normalized origin and cookies issued by that origin; it never - contains a password, request body, or server response body. - - ``_lock`` guards only the in-memory configuration (origin, cookie jar, - source, and status) and the credential file. It is never held across a - network request: the event loop reads ``status`` constantly, so one slow - or unreachable WebUI request would otherwise stall every local RPC, - stream relay, and keepalive for its whole timeout, and serialize all - remote traffic behind it. Requests snapshot the configuration, run - unlocked, and apply an expiry only if that configuration is still current. - """ - - def __init__(self, path: Path, environment_url: str | None = None): - self.path = path - self._lock = threading.RLock() - self.cookie_jar = CookieJar() - self.base_url = "" - self.source = "none" - self.environment_url = "" - self._status = self._make_status( - "disconnected", message="Remote Hermes is not configured" - ) - configured_environment = ( - environment_url - if environment_url is not None - else os.environ.get("HERMES_REMOTE_URL", "") - ) - if configured_environment: - try: - self.environment_url = normalize_remote_url(configured_environment) - except RpcFault: - self._status = self._make_status( - "error", - message="HERMES_REMOTE_URL is invalid", - error_kind="configuration", - ) - file_exists = self.path.exists() - if file_exists: - self._load() - elif self.environment_url: - self.base_url = self.environment_url - self.source = "environment" - self._status = self._make_status( - "disconnected", - configured=True, - url=self.base_url, - message="Remote Hermes has not been checked", - ) - - @property - def status(self) -> dict[str, Any]: - # Writers replace ``_status`` wholesale under ``_lock``, so copying the - # current reference always yields one complete status. Reading it - # lock-free keeps the event loop off a writer's critical section. - return dict(self._status) - - @staticmethod - def _jar_cookies(jar: CookieJar) -> list[Cookie]: - # Requests update a shared jar from worker threads under the jar's - # own lock. Iterate under that lock as well, so a concurrent - # Set-Cookie cannot resize its dictionaries mid-iteration. - with jar._cookies_lock: - return list(jar) - - def connecting_status(self, message: str, url: Any = None) -> dict[str, Any]: - with self._lock: - display_url = self.base_url - if url: - display_url = normalize_remote_url(url) - self._status = self._make_status( - "connecting", - configured=bool(display_url), - url=display_url, - message=message, - ) - return dict(self._status) - - def _make_status( - self, - state: str, - *, - configured: bool | None = None, - url: str | None = None, - reachable: bool = False, - auth_enabled: bool = False, - authenticated: bool = False, - logged_in: bool = False, - password_auth_enabled: bool = False, - message: str = "", - error_kind: str = "", - status_code: int = 0, - source: str | None = None, - ) -> dict[str, Any]: - selected_url = self.base_url if url is None else url - selected_configured = bool(selected_url) if configured is None else configured - return { - "state": state, - "configured": selected_configured, - "url": selected_url, - "origin": selected_url, - "reachable": reachable, - "authEnabled": auth_enabled, - "authenticated": authenticated, - "loggedIn": logged_in, - "passwordAuthEnabled": password_auth_enabled, - "authRequired": state == "expired", - "hasSessionCredential": bool(self._jar_cookies(self.cookie_jar)), - "source": self.source if source is None else source, - "message": message, - "error": message if state in {"expired", "error"} else "", - "errorKind": error_kind, - "statusCode": status_code, - "updatedAt": utc_now(), - } - - def _load(self) -> None: - try: - document = json.loads(self.path.read_text(encoding="utf-8")) - if not isinstance(document, dict): - raise ValueError("credential document is not an object") - base_url = normalize_remote_url(document.get("base_url")) - rows = document.get("cookies", []) - if not isinstance(rows, list): - raise ValueError("credential cookie list is invalid") - jar = CookieJar() - for row in rows: - cookie = self._cookie_from_row(row, base_url) - if cookie is not None: - jar.set_cookie(cookie) - self.base_url = base_url - self.cookie_jar = jar - self.source = "persisted" - with suppress(OSError): - os.chmod(self.path, 0o600) - self._status = self._make_status( - "disconnected", - configured=True, - url=base_url, - message="Saved remote session has not been checked", - ) - except FileNotFoundError: - return - except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError, RpcFault): - # Never include credential document contents in diagnostics. - LOG.warning("could not load remote Hermes credentials from %s", self.path) - self.base_url = "" - self.cookie_jar = CookieJar() - self.source = "none" - self._status = self._make_status( - "error", - configured=False, - url="", - message="Saved remote Hermes credentials are invalid", - error_kind="credentials", - ) - - @staticmethod - def _cookie_from_row(row: Any, base_url: str) -> Cookie | None: - if not isinstance(row, dict): - return None - name = row.get("name") - value = row.get("value") - domain = str(row.get("domain") or "").lstrip(".").lower().rstrip(".") - origin_host = (urlparse(base_url).hostname or "").lower().rstrip(".") - path = str(row.get("path") or "/") - if ( - not isinstance(name, str) - or not name - or len(name) > 256 - or not isinstance(value, str) - or len(value) > 16384 - or any(ord(character) < 0x20 for character in name + value) - or domain != origin_host - or not path.startswith("/") - or len(path) > 2048 - ): - return None - expires_raw = row.get("expires") - try: - expires = int(expires_raw) if expires_raw is not None else None - except (TypeError, ValueError): - return None - if expires is not None and expires <= int(time.time()): - return None - return Cookie( - version=0, - name=name, - value=value, - port=None, - port_specified=False, - domain=domain, - domain_specified=bool(row.get("domain_specified", False)), - domain_initial_dot=False, - path=path, - path_specified=True, - secure=bool(row.get("secure", False)), - expires=expires, - discard=expires is None, - comment=None, - comment_url=None, - rest={"HttpOnly": None} if row.get("http_only", True) else {}, - rfc2109=False, - ) - - def _cookie_rows(self, base_url: str, jar: CookieJar) -> list[dict[str, Any]]: - origin_host = (urlparse(base_url).hostname or "").lower().rstrip(".") - now = time.time() - rows: list[dict[str, Any]] = [] - for cookie in self._jar_cookies(jar): - if cookie.is_expired(now) or cookie.domain.lstrip(".").lower() != origin_host: - continue - rows.append( - { - "name": cookie.name, - "value": cookie.value, - "domain": origin_host, - "domain_specified": cookie.domain_specified, - "path": cookie.path or "/", - "secure": cookie.secure, - "expires": cookie.expires, - "http_only": "HttpOnly" in cookie._rest, - } - ) - return rows - - def _save(self) -> None: - if not self.base_url: - self._delete_file() - return - self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - with suppress(OSError): - os.chmod(self.path.parent, 0o700) - document = { - "version": REMOTE_AUTH_VERSION, - "base_url": self.base_url, - "cookies": self._cookie_rows(self.base_url, self.cookie_jar), - } - temporary = self.path.with_name(f".{self.path.name}.tmp.{os.getpid()}") - descriptor = os.open( - temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 - ) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - json.dump(document, stream, ensure_ascii=False, separators=(",", ":")) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, self.path) - os.chmod(self.path, 0o600) - finally: - with suppress(FileNotFoundError): - temporary.unlink() - - def _delete_file(self) -> None: - try: - self.path.unlink() - except FileNotFoundError: - return - except OSError as exc: - raise RpcFault(-32043, "Could not remove saved remote session") from exc - - @staticmethod - def _login_page_response(response: dict[str, Any]) -> bool: - if response["status"] == 401 or _is_login_url(response["url"]): - return True - location = response["headers"].get("Location", "") - if location and _is_login_url(urljoin(response["url"], location)): - return True - content_type = response["headers"].get("Content-Type", "").lower() - if "text/html" not in content_type: - return False - sample = response["body"][:256 * 1024].decode("utf-8", errors="ignore").lower() - return ( - "login.js" in sample - or "/api/auth/login" in sample - or ("sign in" in sample and "hermes" in sample) - ) - - def _request( - self, - base_url: str, - jar: CookieJar, - method: str, - path: str, - payload: dict[str, Any] | None, - timeout: float, - *, - encoded_body: bytes | None = None, - content_type: str = "", - ) -> dict[str, Any]: - if not path.startswith("/") or "://" in path: - raise RpcFault(-32602, "Remote Hermes API path is invalid") - body = encoded_body - headers = { - "Accept": "application/json", - "User-Agent": "cybexos-hermes-menubar-bridge/1", - } - if encoded_body is not None: - if not content_type: - raise RpcFault(-32602, "Remote Hermes request content type is required") - headers["Content-Type"] = content_type - elif payload is not None: - body = json.dumps( - payload, ensure_ascii=False, separators=(",", ":") - ).encode("utf-8") - headers["Content-Type"] = "application/json" - redirect_handler = _SameOriginRedirectHandler(_remote_origin(base_url)) - opener = build_opener(redirect_handler, HTTPCookieProcessor(jar)) - request = Request( - f"{base_url}{path}", body, headers=headers, method=method.upper() - ) - try: - with opener.open(request, timeout=timeout) as response: - raw = response.read(MAX_REMOTE_AUTH_RESPONSE + 1) - result = { - "status": int(response.status), - "url": response.geturl(), - "headers": response.headers, - "body": raw, - "redirects": list(redirect_handler.redirects), - } - except (_RemoteAuthRequired, _RemoteRedirectBlocked): - raise - except HTTPError as exc: - raw = exc.read(MAX_REMOTE_AUTH_RESPONSE + 1) - result = { - "status": int(exc.code), - "url": exc.geturl(), - "headers": exc.headers, - "body": raw, - "redirects": list(redirect_handler.redirects), - } - except (URLError, TimeoutError, OSError) as exc: - raise _RemoteTransportError() from exc - if len(result["body"]) > MAX_REMOTE_AUTH_RESPONSE: - raise RpcFault(-32041, "Remote Hermes response is too large") - if self._login_page_response(result): - raise _RemoteAuthRequired(result["status"]) - return result - - @staticmethod - def _json_response(response: dict[str, Any]) -> dict[str, Any]: - try: - value = json.loads(response["body"].decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError, TypeError) as exc: - raise RpcFault(-32041, "Remote Hermes returned invalid JSON") from exc - if not isinstance(value, dict): - raise RpcFault(-32041, "Remote Hermes returned invalid JSON") - return value - - @classmethod - def _http_error_fault(cls, response: dict[str, Any]) -> RpcFault: - """Translate a bounded WebUI error without reflecting secrets. - - Hermes WebUI returns typed JSON for recoverable conflicts. Only a - small scalar allow-list crosses the loopback RPC boundary; arbitrary - response objects, headers, cookies, and request content never do. - """ - - status_code = int(response.get("status") or 0) - data: dict[str, Any] = {"statusCode": status_code} - try: - value = cls._json_response(response) - except RpcFault: - value = {} - - error_type = str(value.get("type") or "").strip().lower() - if re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", error_type): - data["errorType"] = error_type - else: - error_type = "" - - error_code = str(value.get("code") or "").strip().lower() - if re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", error_code): - data["errorCode"] = error_code - else: - error_code = "" - - if isinstance(value.get("retryable"), bool): - data["retryable"] = value["retryable"] - - active_stream_id = str(value.get("active_stream_id") or "").strip() - if re.fullmatch(r"[A-Za-z0-9_-]{1,128}", active_stream_id): - data["activeStreamId"] = active_stream_id - else: - active_stream_id = "" - - raw_message = value.get("error") - if not isinstance(raw_message, str): - raw_message = value.get("message") - if not isinstance(raw_message, str): - raw_message = "" - remote_message = re.sub(r"\s+", " ", "".join( - character for character in raw_message - if ord(character) >= 0x20 and ord(character) != 0x7f - )).strip()[:500] - sensitive_words = re.compile( - r"password|passphrase|api[ _-]?key|authorization|cookie|secret|token", - re.IGNORECASE, - ) - if remote_message and not sensitive_words.search(remote_message): - data["remoteMessage"] = remote_message - - if error_type == "agent_runtime_stale": - message = ( - "Remote Hermes fell back to a stale in-process Agent runtime. " - "Restore gateway-backed chat, then retry; this prompt was not accepted." - ) - elif active_stream_id or error_type in { - "active_stream", - "chat_already_running", - "session_busy", - "stream_conflict", - }: - message = ( - "This Hermes session already has an active response; " - "the new prompt was not accepted." - ) - elif error_code == "stale_regeneration_revision": - message = "This conversation changed; refresh it before regenerating" - elif error_code == "unsupported_regeneration_backend": - message = "Regeneration is unavailable on this Hermes backend" - elif error_code == "invalid_regeneration_request": - message = "Hermes rejected the regeneration request" - else: - message = f"Remote Hermes returned HTTP {status_code}" - - return RpcFault(-32041, message, data) - - def _probe_base( - self, - base_url: str, - jar: CookieJar, - *, - configured: bool, - source: str, - timeout: float, - ) -> dict[str, Any]: - # The source is passed through rather than set on ``self``: probes run - # without ``_lock``, so shared state must not change for their sake. - try: - response = self._request( - base_url, jar, "GET", "/api/auth/status", None, timeout - ) - if not 200 <= response["status"] < 300: - return self._make_status( - "error", - configured=configured, - url=base_url, - source=source, - reachable=True, - message=f"Remote Hermes returned HTTP {response['status']}", - error_kind="http", - status_code=response["status"], - ) - value = self._json_response(response) - if "auth_enabled" not in value or "logged_in" not in value: - raise RpcFault(-32041, "Remote endpoint is not a compatible Hermes WebUI") - auth_enabled = value.get("auth_enabled") is True - logged_in = value.get("logged_in") is True - connected = not auth_enabled or logged_in - return self._make_status( - "connected" if connected else "expired", - configured=configured, - url=base_url, - source=source, - reachable=True, - auth_enabled=auth_enabled, - authenticated=connected, - logged_in=logged_in, - password_auth_enabled=value.get("password_auth_enabled") is True, - message=( - "Remote Hermes is connected" - if connected - else "Remote Hermes authentication is required" - ), - ) - except _RemoteAuthRequired as exc: - return self._make_status( - "expired", - configured=configured, - url=base_url, - source=source, - reachable=True, - auth_enabled=True, - message="Remote Hermes authentication is required", - status_code=exc.status_code, - ) - except _RemoteRedirectBlocked: - return self._make_status( - "error", - configured=configured, - url=base_url, - source=source, - reachable=True, - message="Remote Hermes attempted a cross-origin redirect", - error_kind="redirect", - ) - except _RemoteTransportError: - return self._make_status( - "error", - configured=configured, - url=base_url, - source=source, - reachable=False, - message="Remote Hermes is unreachable", - error_kind="offline", - ) - except RpcFault as fault: - return self._make_status( - "error", - configured=configured, - url=base_url, - source=source, - reachable=True, - message=fault.message, - error_kind="protocol", - ) - - async def probe(self, url: Any = None, timeout: float = 10.0) -> dict[str, Any]: - return await asyncio.to_thread(self._probe_sync, url, timeout) - - def _probe_sync(self, url: Any, timeout: float) -> dict[str, Any]: - with self._lock: - if url is not None and str(url).strip(): - base_url = normalize_remote_url(url) - else: - base_url = self.base_url - if not base_url: - self._status = self._make_status( - "disconnected", message="Remote Hermes is not configured" - ) - return dict(self._status) - is_current = base_url == self.base_url - jar = self.cookie_jar if is_current else CookieJar() - source = self.source if is_current else "candidate" - status = self._probe_base( - base_url, - jar, - configured=is_current, - source=source, - timeout=timeout, - ) - if not is_current: - return status - with self._lock: - if self.base_url != base_url or self.cookie_jar is not jar: - # A sign-in or sign-out replaced this session while it was - # being probed; the replacement's status is authoritative. - return dict(self._status) - if status["state"] == "expired" and self._jar_cookies(jar): - self.cookie_jar = CookieJar() - if self.source == "persisted": - self._save() - status["hasSessionCredential"] = False - self._status = status - return dict(status) - - async def login( - self, url: Any, password: Any, timeout: float = 15.0 - ) -> dict[str, Any]: - if not isinstance(password, str) or not password: - raise RpcFault(-32602, "Password is required") - if len(password.encode("utf-8")) > 65536: - raise RpcFault(-32602, "Password is too large") - return await asyncio.to_thread(self._login_sync, url, password, timeout) - - def _login_sync( - self, url: Any, password: str, timeout: float - ) -> dict[str, Any]: - # The sign-in uses its own jar, so the requests need no shared state; - # only committing the resulting session below takes the lock. - base_url = normalize_remote_url(url) - jar = CookieJar() - try: - response = self._request( - base_url, - jar, - "POST", - "/api/auth/login", - {"password": password}, - timeout, - ) - except _RemoteAuthRequired as exc: - with self._lock: - status = self._make_status( - "expired", - configured=base_url == self.base_url, - url=base_url, - reachable=True, - auth_enabled=True, - message="Remote Hermes rejected the sign-in", - status_code=exc.status_code, - ) - if base_url == self.base_url or not self.base_url: - self._status = status - raise RemoteLoginFault("Remote Hermes rejected the sign-in", status) from None - except _RemoteRedirectBlocked: - status = self._make_status( - "error", - configured=base_url == self.base_url, - url=base_url, - reachable=True, - message="Remote Hermes attempted a cross-origin redirect", - error_kind="redirect", - ) - raise RemoteLoginFault(status["message"], status, -32041) from None - except _RemoteTransportError: - status = self._make_status( - "error", - configured=base_url == self.base_url, - url=base_url, - reachable=False, - message="Remote Hermes is unreachable", - error_kind="offline", - ) - raise RemoteLoginFault(status["message"], status, -32042) from None - if response["status"] == 429: - status = self._make_status( - "error", - configured=base_url == self.base_url, - url=base_url, - reachable=True, - message="Remote Hermes temporarily rate-limited sign-in", - error_kind="rate-limit", - ) - raise RemoteLoginFault(status["message"], status, -32044) - if not 200 <= response["status"] < 300: - status = self._make_status( - "error", - configured=base_url == self.base_url, - url=base_url, - reachable=True, - message=f"Remote Hermes returned HTTP {response['status']}", - error_kind="http", - status_code=response["status"], - ) - raise RemoteLoginFault(status["message"], status, -32041) - value = self._json_response(response) - if value.get("ok") is not True: - status = self._make_status( - "expired", - configured=base_url == self.base_url, - url=base_url, - reachable=True, - auth_enabled=True, - message="Remote Hermes rejected the sign-in", - ) - raise RemoteLoginFault(status["message"], status) - status = self._probe_base( - base_url, - jar, - configured=True, - source="persisted", - timeout=timeout, - ) - if status["state"] != "connected": - message = ( - "Remote Hermes rejected the sign-in" - if status["state"] == "expired" - else status["message"] - ) - raise RemoteLoginFault(message, status) - with self._lock: - self.base_url = base_url - self.cookie_jar = jar - self.source = "persisted" - status["source"] = self.source - status["hasSessionCredential"] = bool(self._jar_cookies(jar)) - self._status = status - self._save() - return dict(status) - - async def logout(self, timeout: float = 10.0) -> dict[str, Any]: - return await asyncio.to_thread(self._logout_sync, timeout) - - def _logout_sync(self, timeout: float) -> dict[str, Any]: - with self._lock: - base_url, jar = self.base_url, self.cookie_jar - remote_logout = False - if base_url: - try: - response = self._request( - base_url, - jar, - "POST", - "/api/auth/logout", - {}, - timeout, - ) - remote_logout = 200 <= response["status"] < 300 - except ( - _RemoteAuthRequired, - _RemoteRedirectBlocked, - _RemoteTransportError, - RpcFault, - ): - # Local credential removal is authoritative even when the - # remote session has already expired or is unreachable. - remote_logout = False - with self._lock: - self.cookie_jar = CookieJar() - self._delete_file() - self.base_url = self.environment_url - self.source = "environment" if self.environment_url else "none" - self._status = self._make_status( - "disconnected", - configured=bool(self.base_url), - url=self.base_url, - message=( - "Remote Hermes signed out" - if remote_logout - else "Saved remote session was removed" - ), - ) - result = dict(self._status) - result["remoteLogout"] = remote_logout - return result - - async def request_json( - self, - method: str, - path: str, - payload: dict[str, Any] | None = None, - timeout: float = 30.0, - ) -> dict[str, Any]: - """Reusable authenticated request primitive for the remote adapter.""" - - return await asyncio.to_thread( - self._request_json_sync, method, path, payload, timeout - ) - - async def upload_file( - self, - path: str, - fields: dict[str, str], - filename: str, - data: bytes, - mime_type: str, - timeout: float = 60.0, - ) -> dict[str, Any]: - """Upload one bounded file with the saved WebUI session cookie.""" - - return await asyncio.to_thread( - self._upload_file_sync, - path, - fields, - filename, - data, - mime_type, - timeout, - ) - - async def probe_multipart_route( - self, path: str, timeout: float = 15.0 - ) -> int: - """Return a multipart route's status after a fully consumed empty form.""" - - return await asyncio.to_thread( - self._probe_multipart_route_sync, path, timeout - ) - - def _probe_multipart_route_sync(self, path: str, timeout: float) -> int: - boundary = "----HermesMenubarProbe" + uuid.uuid4().hex - encoded = ( - f"--{boundary}\r\n" - 'Content-Disposition: form-data; name="session_id"\r\n\r\n' - "\r\n" - f"--{boundary}--\r\n" - ).encode("ascii") - base_url, jar = self._current_session() - try: - response = self._request( - base_url, - jar, - "POST", - path, - None, - timeout, - encoded_body=encoded, - content_type=f"multipart/form-data; boundary={boundary}", - ) - except _RemoteAuthRequired as exc: - raise self._session_fault(base_url, jar, exc.status_code) from None - except _RemoteRedirectBlocked: - raise RpcFault( - -32041, "Remote Hermes attempted a cross-origin redirect" - ) from None - except _RemoteTransportError: - raise RpcFault(-32042, "Remote Hermes is unreachable") from None - if response["status"] == 401: - raise self._session_fault(base_url, jar, 401) - return int(response["status"]) - - def _upload_file_sync( - self, - path: str, - fields: dict[str, str], - filename: str, - data: bytes, - mime_type: str, - timeout: float, - ) -> dict[str, Any]: - if len(data) > MAX_REMOTE_ATTACHMENT_BYTES: - raise RpcFault(-32602, "Attachment is larger than 20 MiB") - safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", Path(filename).name)[:200] - if not safe_name or safe_name.strip(".") == "": - safe_name = "attachment" - boundary = "----HermesMenubar" + uuid.uuid4().hex - chunks: list[bytes] = [] - for name, value in fields.items(): - safe_field = re.sub(r"[^A-Za-z0-9_-]", "", str(name))[:80] - if not safe_field: - continue - chunks.extend([ - f"--{boundary}\r\n".encode("ascii"), - ( - f'Content-Disposition: form-data; name="{safe_field}"\r\n\r\n' - ).encode("ascii"), - str(value).encode("utf-8"), - b"\r\n", - ]) - chunks.extend([ - f"--{boundary}\r\n".encode("ascii"), - ( - 'Content-Disposition: form-data; name="file"; ' - f'filename="{safe_name}"\r\n' - ).encode("ascii"), - f"Content-Type: {mime_type or 'application/octet-stream'}\r\n\r\n".encode( - "ascii", errors="replace" - ), - data, - b"\r\n", - f"--{boundary}--\r\n".encode("ascii"), - ]) - encoded = b"".join(chunks) - - base_url, jar = self._current_session() - try: - response = self._request( - base_url, - jar, - "POST", - path, - None, - timeout, - encoded_body=encoded, - content_type=f"multipart/form-data; boundary={boundary}", - ) - except _RemoteAuthRequired as exc: - raise self._session_fault(base_url, jar, exc.status_code) from None - except _RemoteRedirectBlocked: - raise RpcFault( - -32041, "Remote Hermes attempted a cross-origin redirect" - ) from None - except _RemoteTransportError: - raise RpcFault(-32042, "Remote Hermes is unreachable") from None - if response["status"] == 401: - raise self._session_fault(base_url, jar, 401) - if not 200 <= response["status"] < 300: - raise self._http_error_fault(response) - return self._json_response(response) - - def _request_json_sync( - self, - method: str, - path: str, - payload: dict[str, Any] | None, - timeout: float, - ) -> dict[str, Any]: - base_url, jar = self._current_session() - try: - response = self._request( - base_url, - jar, - method, - path, - payload, - timeout, - ) - except _RemoteAuthRequired as exc: - raise self._session_fault(base_url, jar, exc.status_code) from None - except _RemoteRedirectBlocked: - raise RpcFault( - -32041, "Remote Hermes attempted a cross-origin redirect" - ) from None - except _RemoteTransportError: - if method.upper() == "POST" and path == "/api/chat/start": - raise AmbiguousDelivery("remote prompt.submit") from None - raise RpcFault(-32042, "Remote Hermes is unreachable") from None - if response["status"] == 401: - raise self._session_fault(base_url, jar, 401) - if not 200 <= response["status"] < 300: - raise self._http_error_fault(response) - return self._json_response(response) - - async def probe_contract(self, timeout: float = 10.0) -> dict[str, Any]: - """Read the WebUI's non-streaming SSE capability probe and server tag.""" - - return await asyncio.to_thread(self._probe_contract_sync, timeout) - - def _probe_contract_sync(self, timeout: float) -> dict[str, Any]: - base_url, jar = self._current_session() - try: - response = self._request( - base_url, - jar, - "GET", - "/api/sessions/gateway/stream?probe=1", - None, - timeout, - ) - except _RemoteAuthRequired as exc: - raise self._session_fault(base_url, jar, exc.status_code) from None - except _RemoteRedirectBlocked: - raise RpcFault( - -32041, "Remote Hermes attempted a cross-origin redirect" - ) from None - except _RemoteTransportError: - raise RpcFault(-32042, "Remote Hermes is unreachable") from None - - server = re.sub( - r"[^A-Za-z0-9._/ +()-]", "", str(response["headers"].get("Server", "")) - ).strip()[:128] - try: - value = self._json_response(response) - except RpcFault: - value = {} - session_path = str(value.get("session_stream_path") or "") - if not session_path.startswith("/api/") or "://" in session_path: - session_path = "/api/session/stream" - try: - fallback_poll_ms = int(value.get("fallback_poll_ms") or 30000) - except (TypeError, ValueError): - fallback_poll_ms = 30000 - return { - "checked": True, - "probeStatus": int(response.get("status") or 0), - "server": server, - "gatewaySessions": value.get("ok") is True, - "gatewayWatcher": value.get("watcher_running") is True, - "sessionStream": value.get("session_stream_available") is True, - "sessionStreamPath": session_path, - "fallbackPollMs": max(5000, min(300000, fallback_poll_ms)), - } - - def _current_session(self) -> tuple[str, CookieJar]: - """Snapshot the origin and cookie jar one unlocked request will use.""" - - with self._lock: - if not self.base_url: - raise RpcFault(-32040, "Remote Hermes is not configured") - return self.base_url, self.cookie_jar - - def _session_fault( - self, base_url: str, jar: CookieJar, status_code: int = 401 - ) -> RpcFault: - """Expire the session a challenged request used, if it is still current.""" - - with self._lock: - if self.base_url == base_url and self.cookie_jar is jar: - status = self._expire_session_locked(status_code) - elif self._status.get("state") == "expired": - # A concurrent request already expired this session. - status = dict(self._status) - else: - # A sign-in, sign-out, or origin change finished while this - # request was in flight. Its challenge describes a session that - # is already gone, so it must neither clear the replacement's - # cookies nor report the replacement as expired. - return RpcFault( - -32042, "Remote Hermes session changed during the request" - ) - return RemoteLoginFault(status["message"], status) - - def _expire_session_locked(self, status_code: int = 401) -> dict[str, Any]: - self.cookie_jar = CookieJar() - if self.source == "persisted": - self._save() - self._status = self._make_status( - "expired", - configured=bool(self.base_url), - url=self.base_url, - reachable=True, - auth_enabled=True, - message="Remote Hermes authentication is required", - status_code=status_code, - ) - return dict(self._status) - - def open_sse( - self, - path: str, - *, - last_event_id: str = "", - timeout: float = 45.0, - ) -> Any: - """Open one authenticated, same-origin WebUI SSE response. - - This synchronous primitive is intended to be called through - ``asyncio.to_thread``. The caller owns and must close the returned - response. Cookie values and redirect destinations never leave the - bridge process. - """ - - base_url, jar = self._current_session() - if not isinstance(path, str) or not path.startswith("/") or "://" in path: - raise RpcFault(-32602, "Remote Hermes API path is invalid") - headers = { - "Accept": "text/event-stream", - "Cache-Control": "no-cache", - "User-Agent": "cybexos-hermes-menubar-bridge/1", - } - if last_event_id: - headers["Last-Event-ID"] = str(last_event_id)[:1024] - redirect_handler = _SameOriginRedirectHandler(_remote_origin(base_url)) - opener = build_opener(redirect_handler, HTTPCookieProcessor(jar)) - request = Request(f"{base_url}{path}", headers=headers, method="GET") - try: - response = opener.open(request, timeout=timeout) - except _RemoteAuthRequired as exc: - raise self._session_fault(base_url, jar, exc.status_code) from None - except _RemoteRedirectBlocked: - raise RpcFault( - -32041, "Remote Hermes attempted a cross-origin redirect" - ) from None - except HTTPError as exc: - status_code = int(exc.code) - with suppress(Exception): - exc.close() - if status_code == 401: - raise self._session_fault(base_url, jar, status_code) from None - raise RpcFault( - -32041, f"Remote Hermes returned HTTP {status_code}" - ) from None - except (URLError, TimeoutError, OSError): - raise RpcFault(-32042, "Remote Hermes stream is unreachable") from None - - status_code = int(getattr(response, "status", 0) or 0) - content_type = str(response.headers.get("Content-Type", "")).lower() - final_url = str(response.geturl() or "") - if _is_login_url(final_url) or "text/html" in content_type: - with suppress(Exception): - response.close() - raise self._session_fault(base_url, jar, status_code or 302) - if status_code != 200 or "text/event-stream" not in content_type: - with suppress(Exception): - response.close() - raise RpcFault( - -32041, "Remote Hermes returned an invalid event stream" - ) - return response - - def authenticated_headers(self, path: str = "/") -> dict[str, str]: - """Return an origin-scoped Cookie header for an internal SSE adapter. - - The returned value is a credential and must never be sent downstream or - logged. Accepting only an absolute-path reference prevents callers from - accidentally forwarding it to another origin. - """ - - with self._lock: - if not self.base_url: - raise RpcFault(-32040, "Remote Hermes is not configured") - if not isinstance(path, str) or not path.startswith("/") or "://" in path: - raise RpcFault(-32602, "Remote Hermes API path is invalid") - request = Request(f"{self.base_url}{path}") - self.cookie_jar.add_cookie_header(request) - cookie = request.get_header("Cookie") - return {"Cookie": cookie} if cookie else {} - - -def default_conversations() -> list[dict[str, Any]]: - # New chat is a client-side virtual selection, not a persisted session. - # Historical rows are hydrated from the WebUI's native /api/sessions list. - return [] - - -class ConversationRegistry: - """Small atomic JSON registry; prompts and Hermes credentials never enter it.""" - - def __init__(self, path: Path): - self.path = path - self.conversations: dict[str, dict[str, Any]] = {} - self.selected_conversation_id = "" - self._save_handle: asyncio.TimerHandle | None = None - self._load() - - def _load(self) -> None: - document: dict[str, Any] | None = None - try: - document = json.loads(self.path.read_text(encoding="utf-8")) - except FileNotFoundError: - pass - except (OSError, json.JSONDecodeError, TypeError) as exc: - LOG.error("could not load conversation registry %s: %s", self.path, exc) - - rows = document.get("conversations") if isinstance(document, dict) else None - if isinstance(rows, list): - for row in rows: - conversation = self._coerce_conversation(row) - if conversation is not None and conversation["id"] not in self.conversations: - self.conversations[conversation["id"]] = conversation - - # Selection intentionally never survives a bridge restart. The widget - # always opens on a fresh chat while the list remains available. - self.selected_conversation_id = "" - - @staticmethod - def _coerce_conversation(row: Any) -> dict[str, Any] | None: - if not isinstance(row, dict): - return None - conversation_id = str( - row.get("session_id") or row.get("sessionId") or row.get("id") or "" - ).strip() - title = str(row.get("title") or row.get("name") or "Untitled chat").strip() - if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id): - return None - status = str(row.get("status") or "idle") - if status not in { - "idle", - "working", - "waiting", - "done", - "error", - "offline", - "reconnecting", - }: - status = "idle" - stored_session_id = str( - row.get("stored_session_id") or row.get("storedSessionId") or "" - )[:256] - remote_origin = str( - row.get("remote_origin") or row.get("remoteOrigin") or "" - )[:2048] - remote_session_id = str( - row.get("remote_session_id") or row.get("remoteSessionId") or "" - )[:256] - return { - "id": conversation_id, - "name": title[:160], - "title": title[:160] or "Untitled chat", - "brief": str(row.get("brief") or "")[:4000], - "profile": str(row.get("profile") or "")[:128], - "cwd": str(row.get("cwd") or "")[:4096], - "stored_session_id": stored_session_id, - "remote_origin": remote_origin, - "remote_session_id": remote_session_id, - # Missing on old registries: be conservative and assume a durable - # id may contain user history. Only known-empty lazy sessions are - # safe to recreate after a session-not-found resume. - "has_messages": bool( - row.get("has_messages", bool(stored_session_id or remote_session_id)) - ), - "status": status, - "status_text": str(row.get("status_text") or "Ready")[:240], - "unread": bool(row.get("unread", False)), - "updated_at": str(row.get("updated_at") or utc_now()), - "created_at": str(row.get("created_at") or ""), - "model": str(row.get("model") or "")[:256], - "model_provider": str( - row.get("model_provider") or row.get("modelProvider") or "" - )[:128], - "source": str( - row.get("source") - or row.get("source_label") - or row.get("sourceLabel") - or row.get("session_source") - or row.get("sessionSource") - or "" - )[:128], - "read_only": bool(row.get("read_only", row.get("readOnly", False))), - "message_count": ConversationRegistry._message_count( - row.get("message_count") - ), - } - - @staticmethod - def _message_count(value: Any) -> int: - try: - return max(0, int(value or 0)) - except (TypeError, ValueError, OverflowError): - return 0 - - def save_later(self, delay: float = REGISTRY_SAVE_DELAY) -> None: - """Coalesce frequent status writes into one atomic save per window.""" - if self._save_handle is not None: - return - try: - loop = asyncio.get_running_loop() - except RuntimeError: - self.save() - return - self._save_handle = loop.call_later(delay, self._deferred_save) - - def _deferred_save(self) -> None: - self._save_handle = None - try: - self.save() - except OSError as exc: - LOG.error("could not save conversation registry %s: %s", self.path, exc) - - def flush(self) -> None: - """Write a pending coalesced save now (used on shutdown).""" - if self._save_handle is not None: - self.save() - - def save(self) -> None: - if self._save_handle is not None: - self._save_handle.cancel() - self._save_handle = None - self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - with suppress(OSError): - os.chmod(self.path.parent, 0o700) - document = { - "version": BRIDGE_VERSION, - "selected_conversation_id": self.selected_conversation_id, - "conversations": list(self.conversations.values()), - } - temporary = self.path.with_name(f".{self.path.name}.tmp.{os.getpid()}") - descriptor = os.open( - temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 - ) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - json.dump(document, stream, ensure_ascii=False, indent=2) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, self.path) - os.chmod(self.path, 0o600) - finally: - with suppress(FileNotFoundError): - temporary.unlink() - - -@dataclass(eq=False) -class LocalClient: - """One loopback shell connection with its own bounded outbound queue. - - Producers never await the socket: a dedicated writer task drains the - queue, so one stalled client cannot block the upstream reader (and with - it the gateway heartbeat) or delay delivery to other clients. A client - that falls MAX_CLIENT_BACKLOG frames behind is disconnected; the shell - reconnects and reconciles through its normal hello/list/history path. - """ - - websocket: ServerConnection - tasks: set[asyncio.Task[Any]] = field(default_factory=set) - backlog: int = MAX_CLIENT_BACKLOG - closed: bool = False - queue: asyncio.Queue[str] = field(init=False) - writer: asyncio.Task[Any] | None = field(default=None, init=False) - - def __post_init__(self) -> None: - self.queue = asyncio.Queue(maxsize=max(1, self.backlog)) - - def start(self) -> None: - if self.writer is None: - self.writer = asyncio.create_task( - self._write(), name="hermes-local-writer" - ) - - async def _write(self) -> None: - try: - while True: - text = await self.queue.get() - await self.websocket.send(text) - except ConnectionClosed: - pass - except asyncio.CancelledError: - raise - except Exception as exc: - LOG.debug("local client write failed: %s", exc) - finally: - self.closed = True - - def enqueue_text(self, text: str) -> bool: - if self.closed: - return False - try: - self.queue.put_nowait(text) - except asyncio.QueueFull: - LOG.warning( - "local Hermes client fell %d frames behind; disconnecting it", - self.queue.maxsize, - ) - self.abort("client too slow") - return False - return True - - def abort(self, reason: str) -> None: - if self.closed: - return - self.closed = True - if self.writer is not None: - self.writer.cancel() - closer = asyncio.create_task( - self.websocket.close(code=1013, reason=reason), - name="hermes-local-close", - ) - self.tasks.add(closer) - closer.add_done_callback(self._closed) - - def _closed(self, task: asyncio.Task[Any]) -> None: - self.tasks.discard(task) - if not task.cancelled(): - task.exception() - - async def stop(self) -> None: - self.closed = True - if self.writer is not None: - self.writer.cancel() - with suppress(asyncio.CancelledError, Exception): - await self.writer - - async def send(self, frame: dict[str, Any]) -> None: - self.enqueue_text(json_frame(frame)) - - -@dataclass -class PendingUpstream: - method: str - future: asyncio.Future[Any] - written: bool = False - - -class HermesGateway: - """Authenticated, reconnecting JSON-RPC client for ``hermes serve``.""" - - def __init__( - self, - base_url: str, - on_event: Callable[[dict[str, Any]], Awaitable[None]], - on_state: Callable[[str, str], Awaitable[None]], - on_ready: Callable[[str], Awaitable[None]], - ): - self.base_url = base_url.rstrip("/") - self.on_event = on_event - self.on_state = on_state - self.on_ready = on_ready - self.websocket: ClientConnection | None = None - self.connected = asyncio.Event() - self.ready_epoch = "" - self._pending: dict[str, PendingUpstream] = {} - self._next_id = 0 - self._send_lock = asyncio.Lock() - self._stop = asyncio.Event() - self._runner: asyncio.Task[Any] | None = None - - def start(self) -> None: - if self._runner is None: - self._runner = asyncio.create_task(self._run(), name="hermes-upstream") - - async def stop(self) -> None: - self._stop.set() - websocket = self.websocket - if websocket is not None: - with suppress(Exception): - await websocket.close(code=1001, reason="bridge stopping") - if self._runner is not None: - self._runner.cancel() - with suppress(asyncio.CancelledError): - await self._runner - - async def request( - self, method: str, params: dict[str, Any] | None = None, timeout: float = 30.0 - ) -> Any: - if not self.connected.is_set() or self.websocket is None: - raise UpstreamUnavailable() - self._next_id += 1 - request_id = f"menubar-{self._next_id}" - future = asyncio.get_running_loop().create_future() - pending = PendingUpstream(method=method, future=future) - self._pending[request_id] = pending - frame = { - "jsonrpc": "2.0", - "id": request_id, - "method": method, - "params": params or {}, - } - try: - async with self._send_lock: - websocket = self.websocket - if websocket is None: - raise UpstreamUnavailable() - await websocket.send(json_frame(frame)) - pending.written = True - return await asyncio.wait_for(future, timeout=timeout) - except asyncio.TimeoutError as exc: - self._pending.pop(request_id, None) - if method == "prompt.submit" and pending.written: - raise AmbiguousDelivery(method) from exc - raise RpcFault( - -32012, - f"Hermes did not answer {method} within {int(timeout)} seconds", - {"method": method}, - ) from exc - except ConnectionClosed as exc: - self._pending.pop(request_id, None) - if pending.written: - raise AmbiguousDelivery(method) from exc - raise UpstreamUnavailable() from exc - finally: - self._pending.pop(request_id, None) - - async def api_request( - self, - method: str, - path: str, - payload: dict[str, Any] | None = None, - timeout: float = 20.0, - ) -> Any: - """Call an authenticated Hermes dashboard API without exposing its token. - - Provider setup is a dashboard REST API rather than a gateway RPC. The - bridge obtains the same private session token it already uses for the - upstream WebSocket and keeps both that token and submitted credentials - out of downstream responses and logs. - """ - return await asyncio.to_thread( - self._api_request_sync, method, path, payload, timeout - ) - - def _api_request_sync( - self, - method: str, - path: str, - payload: dict[str, Any] | None, - timeout: float, - ) -> Any: - if not path.startswith("/api/") or "://" in path: - raise RpcFault(-32602, "invalid Hermes API path") - try: - token = self._fetch_token() - except Exception as exc: - raise UpstreamUnavailable("Hermes provider API is unavailable") from exc - body = ( - json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode( - "utf-8" - ) - if payload is not None - else None - ) - headers = { - "Accept": "application/json", - "User-Agent": "cybexos-hermes-menubar-bridge/1", - "X-Hermes-Session-Token": token, - } - if body is not None: - headers["Content-Type"] = "application/json" - request = Request( - f"{self.base_url}{path}", - data=body, - method=method.upper(), - headers=headers, - ) - try: - with urlopen(request, timeout=timeout) as response: - raw = response.read(MAX_PROVIDER_RESPONSE + 1) - except HTTPError as exc: - raw = exc.read(MAX_PROVIDER_RESPONSE + 1) - message = self._api_error_message(raw) - raise RpcFault( - -32030, - message or f"Hermes provider API returned HTTP {exc.code}", - ) from exc - except (URLError, TimeoutError, OSError) as exc: - raise UpstreamUnavailable("Hermes provider API is unavailable") from exc - if len(raw) > MAX_PROVIDER_RESPONSE: - raise RpcFault(-32030, "Hermes provider API response is too large") - if not raw: - return {} - try: - return json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError, TypeError) as exc: - raise RpcFault(-32030, "Hermes provider API returned invalid JSON") from exc - - @staticmethod - def _api_error_message(raw: bytes) -> str: - try: - value = json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError, TypeError): - return "" - if not isinstance(value, dict): - return "" - detail = value.get("detail") or value.get("message") or value.get("error") - return str(detail)[:500] if isinstance(detail, (str, int, float)) else "" - - async def _run(self) -> None: - backoff = 0.5 - while not self._stop.is_set(): - await self.on_state("connecting", "Connecting to Hermes…") - receiver: asyncio.Task[Any] | None = None - heartbeat: asyncio.Task[Any] | None = None - try: - token = await asyncio.to_thread(self._fetch_token) - websocket_url = self._websocket_url(token) - async with websockets.connect( - websocket_url, - open_timeout=15, - close_timeout=5, - max_size=MAX_UPSTREAM_MESSAGE, - ping_interval=20, - ping_timeout=45, - ) as websocket: - self.websocket = websocket - receiver = asyncio.create_task( - self._receive(websocket), name="hermes-upstream-receive" - ) - await asyncio.wait_for(self.connected.wait(), timeout=30) - backoff = 0.5 - await self.on_state("connected", "Hermes connected") - ready_task = asyncio.create_task( - self.on_ready(self.ready_epoch), name="hermes-reconcile" - ) - ready_task.add_done_callback(self._log_background_failure) - heartbeat = asyncio.create_task( - self._heartbeat(websocket), name="hermes-upstream-heartbeat" - ) - await receiver - except asyncio.CancelledError: - raise - except Exception as exc: - if not self._stop.is_set(): - LOG.warning("Hermes connection unavailable: %s", exc) - finally: - self.connected.clear() - self.websocket = None - for task in (receiver, heartbeat): - if task is not None and not task.done(): - task.cancel() - self._reject_pending() - - if self._stop.is_set(): - break - await self.on_state("reconnecting", "Reconnecting to Hermes…") - try: - await asyncio.wait_for( - self._stop.wait(), timeout=backoff + random.random() * 0.25 - ) - except asyncio.TimeoutError: - pass - backoff = min(backoff * 2, 15.0) - - @staticmethod - def _log_background_failure(task: asyncio.Task[Any]) -> None: - if task.cancelled(): - return - exc = task.exception() - if exc is not None: - LOG.error("Hermes reconciliation failed: %s", exc) - - def _fetch_token(self) -> str: - configured = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN", "").strip() - if configured: - return configured - request = Request( - f"{self.base_url}/", - headers={"User-Agent": "cybexos-hermes-menubar-bridge/1"}, - ) - with urlopen(request, timeout=10) as response: - body = response.read(1024 * 1024).decode("utf-8", errors="replace") - match = TOKEN_PATTERN.search(body) - if not match: - raise RuntimeError("Hermes headless token was not present at the root URL") - token = json.loads(match.group(1)) - if not isinstance(token, str) or not token: - raise RuntimeError("Hermes returned an invalid headless token") - return token - - def _websocket_url(self, token: str) -> str: - parsed = urlparse(self.base_url) - if parsed.scheme not in {"http", "https"}: - raise RuntimeError("Hermes upstream must use http:// or https://") - scheme = "wss" if parsed.scheme == "https" else "ws" - path = f"{parsed.path.rstrip('/')}/api/ws" - return urlunparse( - (scheme, parsed.netloc, path, "", f"token={quote(token, safe='')}", "") - ) - - async def _receive(self, websocket: ClientConnection) -> None: - async for raw in websocket: - if not isinstance(raw, str): - continue - try: - frame = json.loads(raw) - except (json.JSONDecodeError, TypeError): - LOG.warning("Hermes sent malformed JSON") - continue - if not isinstance(frame, dict): - continue - request_id = frame.get("id") - if request_id is not None: - pending = self._pending.get(str(request_id)) - if pending is None or pending.future.done(): - continue - error = frame.get("error") - if isinstance(error, dict): - pending.future.set_exception( - RpcFault( - int(error.get("code") or -32000), - str(error.get("message") or "Hermes RPC failed"), - error.get("data"), - ) - ) - else: - pending.future.set_result(frame.get("result")) - continue - if frame.get("method") != "event" or not isinstance( - frame.get("params"), dict - ): - continue - event = frame["params"] - if event.get("type") == "gateway.ready": - payload = event.get("payload") - self.ready_epoch = ( - str(payload.get("replay_epoch") or "") - if isinstance(payload, dict) - else "" - ) - self.connected.set() - await self.on_event(event) - - async def _heartbeat(self, websocket: ClientConnection) -> None: - while websocket is self.websocket and not self._stop.is_set(): - await asyncio.sleep(15) - try: - await self.request("gateway.ping", {}, timeout=10) - except RpcFault: - with suppress(Exception): - await websocket.close(code=1011, reason="heartbeat failed") - return - - def _reject_pending(self) -> None: - for pending in list(self._pending.values()): - if pending.future.done(): - continue - if pending.written: - pending.future.set_exception(AmbiguousDelivery(pending.method)) - else: - pending.future.set_exception(UpstreamUnavailable()) - - class HermesBridge: def __init__( self, diff --git a/roles/desktop/files/hyprland.lua b/roles/desktop/files/hyprland.lua index 8cc91305..b738e0f3 100644 --- a/roles/desktop/files/hyprland.lua +++ b/roles/desktop/files/hyprland.lua @@ -26,7 +26,7 @@ package.path = source_dir .. "/?.lua;" .. source_dir .. "/?/init.lua;" .. generated_dir .. "/?.lua;" .. generated_dir .. "/?/init.lua;" .. user_dir .. "/?.lua;" .. user_dir .. "/?/init.lua;" .. package.path -for _, module in ipairs({ "features", "monitors", "input", "bindings", "looknfeel", "autostart", "displays" }) do +for _, module in ipairs({ "features", "monitors", "input", "input_preferences", "bindings", "looknfeel", "autostart", "displays" }) do package.loaded[module] = nil end @@ -40,6 +40,8 @@ if not displays_ok then _G.__cybexos_displays_error = tostring(displays_error) end require("input") +local input_ok, input_error = pcall(require, "input_preferences") +if not input_ok then _G.__cybexos_input_error = tostring(input_error) end require("bindings") require("looknfeel") require("autostart") diff --git a/roles/desktop/files/input_preferences.lua b/roles/desktop/files/input_preferences.lua new file mode 100644 index 00000000..8edb28ed --- /dev/null +++ b/roles/desktop/files/input_preferences.lua @@ -0,0 +1,69 @@ +-- Personal input data loads after vendor input and before user.lua. Reuse the +-- display module's bounded JSON reader; strings from this file are never code. +local M = {} +local json = require("displays") +local config = os.getenv("XDG_CONFIG_HOME") or ((os.getenv("HOME") or "") .. "/.config") +M.path = config .. "/cybexos/input.json" +local shortcuts = { [""] = true, + ["grp:alt_shift_toggle"] = true, ["grp:ctrl_shift_toggle"] = true, ["grp:caps_toggle"] = true } +local function token(value) + return type(value) == "string" and #value >= 1 and #value <= 64 and value:match("^[%w_-]+$") +end +function M.apply(document) + assert(type(document) == "table" and document.v == 1, "unsupported input preferences version") + local keyboard = document.keyboard == nil and {} or document.keyboard + local touchpad = document.touchpad == nil and {} or document.touchpad + assert(type(keyboard) == "table" and keyboard.n == nil and keyboard ~= json.null, "invalid keyboard preferences") + assert(type(touchpad) == "table" and touchpad.n == nil and touchpad ~= json.null, "invalid touchpad preferences") + local input = {} + if keyboard.layouts ~= nil then + assert(type(keyboard.layouts) == "table" and type(keyboard.layouts.n) == "number" + and keyboard.layouts.n >= 1 and keyboard.layouts.n <= 4, "invalid keyboard layouts") + local layouts, variants = {}, {} + for _, entry in ipairs(keyboard.layouts) do + assert(type(entry) == "table" and token(entry.layout), "invalid keyboard layout") + assert(entry.variant == nil or entry.variant == "" or token(entry.variant), "invalid keyboard variant") + layouts[#layouts + 1], variants[#variants + 1] = entry.layout, entry.variant or "" + end + input.kb_layout, input.kb_variant = table.concat(layouts, ","), table.concat(variants, ",") + end + if keyboard.shortcut ~= nil then + assert(shortcuts[keyboard.shortcut], "invalid layout switching shortcut") + -- Keep the shared vendor options; user.lua can still customize them. + local options = {} + for option in (_G.__cybexos_vendor_keyboard_options or ""):gmatch("[^,]+") do + if not option:match("^grp:") and not (keyboard.shortcut == "grp:caps_toggle" and option == "compose:caps") then + options[#options + 1] = option + end + end + if keyboard.shortcut ~= "" then options[#options + 1] = keyboard.shortcut end + input.kb_options = table.concat(options, ",") + end + input.touchpad = {} + for saved, native in pairs({tap = "tap_to_click", naturalScroll = "natural_scroll"}) do + if touchpad[saved] ~= nil then + assert(type(touchpad[saved]) == "boolean", "invalid touchpad switch") + input.touchpad[native] = touchpad[saved] + end + end + if touchpad.sensitivity ~= nil then + assert(type(touchpad.sensitivity) == "number" and touchpad.sensitivity >= -1 and touchpad.sensitivity <= 1, + "invalid touchpad sensitivity") + -- Hyprland exposes pointer sensitivity at input level; touchpads use it + -- too, unless a user's per-device rule overrides it. + input.sensitivity = touchpad.sensitivity + end + hl.config({ input = input }) +end +local file = io.open(M.path, "rb") +if file then + local contents = file:read(json.MAX_BYTES + 1) + file:close() + local document, error = json.decode(contents) + local ok, reason = false, error + if document ~= nil then ok, reason = pcall(M.apply, document) end + _G.__cybexos_input_error = not ok and tostring(reason) or nil +else + _G.__cybexos_input_error = nil +end +return M diff --git a/roles/desktop/files/quickshell/Common/CommandRequest.qml b/roles/desktop/files/quickshell/Common/CommandRequest.qml new file mode 100644 index 00000000..575796ee --- /dev/null +++ b/roles/desktop/files/quickshell/Common/CommandRequest.qml @@ -0,0 +1,90 @@ +import QtQuick +import Quickshell.Io +import "ProcHelpers.js" as ProcHelpers + +// One bounded subprocess request. Owns launch failure, stream collection and +// cancellation; domain owners only receive a completed response. A timed-out +// process never publishes partial stdout as a successful response. +Item { + id: root + property alias command: process.command + // Request intent is separate from QProcess state. A failed start never + // emits running=true, so resetting on that edge would reuse the previous + // completion flag and strand every subsequent request. + property bool running: false + onRunningChanged: { + if (!running) { + if (process.running) + process.running = false; + return; + } + process.body = ""; + process.error = ""; + process.exitSeen = false; + process.exitCode = ProcHelpers.NOT_STARTED; + timedOut = false; + settled = false; + watchdog.interval = Math.max(1, timeoutMs); + if (timeoutMs > 0) + watchdog.restart(); + process.running = true; + } + property bool stdinEnabled: false + property string inputText: "" + property int timeoutMs: 30000 + property int killGraceMs: 5000 + property string timeoutMessage: "Command timed out" + property bool timedOut: false + property bool settled: false + signal completed(int code, string body, string error) + signal available() + + function finish(code, body, error) { + if (settled) + return; + settled = true; + completed(code, body, error); + } + + function expire() { + if (!process.running) + return; + if (!timedOut) { + timedOut = true; + watchdog.interval = killGraceMs; + watchdog.restart(); + process.running = false; + } else { + process.signal(9); + finish(124, "", timeoutMessage); + } + } + + Process { + id: process + stdinEnabled: root.stdinEnabled + onStarted: if (root.stdinEnabled) write(root.inputText) + property string body: "" + property string error: "" + property bool exitSeen: false + property int exitCode: ProcHelpers.NOT_STARTED + stdout: StdioCollector { onStreamFinished: process.body = text } + stderr: StdioCollector { onStreamFinished: process.error = text } + onExited: code => { + exitSeen = true; + exitCode = code; + } + onRunningChanged: { + if (running) + return; + root.running = false; + watchdog.stop(); + if (root.timedOut) + root.finish(124, "", root.timeoutMessage); + else + root.finish(exitSeen ? exitCode : ProcHelpers.NOT_STARTED, body, error); + root.available(); + } + } + Timer { id: watchdog; onTriggered: root.expire() } +} diff --git a/roles/desktop/files/quickshell/Common/GitHub.qml b/roles/desktop/files/quickshell/Common/GitHub.qml index 9b267509..c242d6f7 100644 --- a/roles/desktop/files/quickshell/Common/GitHub.qml +++ b/roles/desktop/files/quickshell/Common/GitHub.qml @@ -3,6 +3,7 @@ import QtQuick import Quickshell import Quickshell.Io import "GitHubHelpers.js" as Helpers +import "GitHubQueue.js" as Queue import "ProcHelpers.js" as ProcHelpers import "ExternalUrl.js" as ExternalUrl @@ -264,45 +265,15 @@ Singleton { property var active: null function jobKey(job) { - switch (job.kind) { - case "watch": return "watch:" + job.slug; - case "commits": return "commits:" + job.slug; - case "stats": return "stats:" + job.sha; - case "runs": return "runs:" + job.slug + ":" + job.generation; - case "events": return "events:" + job.slug + ":" + job.generation; - case "notifications": return "notifications:" + job.generation; - default: return job.kind; - } + return Queue.jobKey(job); } function enqueue(job) { - const key = jobKey(job); - if (active !== null && jobKey(active) === key) - return false; - const queuedAt = queue.findIndex(queued => jobKey(queued) === key); - if (queuedAt >= 0) { - // A popover request can overlap a background toast/cache read. - // Keep the richer existing job, but move it into the interactive - // lane so deduplication never costs the user's priority. - if (job.interactive === true && !queue[queuedAt].interactive) { - const promoted = Object.assign({}, queue[queuedAt], { interactive: true }); - const without = queue.slice(0, queuedAt).concat(queue.slice(queuedAt + 1)); - const firstBackground = without.findIndex(queued => !queued.interactive); - const at = firstBackground < 0 ? without.length : firstBackground; - queue = without.slice(0, at).concat([promoted], without.slice(at)); - } - return false; - } - const next = Object.assign({}, job, { interactive: job.interactive === true }); - if (!next.interactive) { - queue = queue.concat([next]); - } else { - const firstBackground = queue.findIndex(queued => !queued.interactive); - const at = firstBackground < 0 ? queue.length : firstBackground; - queue = queue.slice(0, at).concat([next], queue.slice(at)); - } - pump(); - return true; + const result = Queue.enqueue(queue, active, job); + queue = result.queue; + if (result.added) + pump(); + return result.added; } function pump() { @@ -333,8 +304,8 @@ Singleton { ghProc.command = command; // Armed before the launch: a binary that cannot start reports its // falling edge at once, and that edge is what stops the watchdog. - ghWatchdog.interval = Helpers.ghTimeoutMs(job); - ghWatchdog.restart(); + ghProc.timeoutMs = Helpers.ghTimeoutMs(job); + ghProc.timeoutMessage = Helpers.ghTimeoutMessage(job); ghProc.running = true; } @@ -363,31 +334,6 @@ Singleton { } } - // A stalled `gh api` would otherwise hold `active` forever: the queue - // stops, and `polling`/`inboxPolling` never fall, so no refresh can start. - // First firing: SIGTERM, reported through the normal falling edge as a - // timeout. If that edge still has not arrived after the grace period, - // SIGKILL and settle the job here so the flags are released regardless. - function ghWatchdogFired() { - if (active === null) - return; - if (ghProc.running && !ghProc.timedOut) { - ghProc.timedOut = true; - ghProc.timeoutText = Helpers.ghTimeoutMessage(active); - console.warn("github:", ghProc.timeoutText + ":", jobKey(active)); - ghWatchdog.interval = Helpers.GH_KILL_GRACE_MS; - ghWatchdog.restart(); - ghProc.running = false; - return; - } - const message = ghProc.timeoutText !== "" ? ghProc.timeoutText - : Helpers.ghTimeoutMessage(active); - if (ghProc.running) - ghProc.signal(9); - ghProc.abandoned = true; - settle(Helpers.GH_TIMEOUT_EXIT, "", message); - } - // Rate-limit headers arrive on every included response, 304s too. function noteRateLimit(headers) { const pause = Helpers.rateLimitPause(headers, Date.now()); @@ -1153,58 +1099,11 @@ Singleton { }); } - Process { + CommandRequest { id: ghProc - property string body: "" - property string errText: "" - property bool exitSeen: false - property int lastExit: 0 - // Watchdog state for the current run; see root.ghWatchdogFired(). - property bool timedOut: false - property bool abandoned: false - property string timeoutText: "" - - stdout: StdioCollector { - onStreamFinished: ghProc.body = text - } - stderr: StdioCollector { - onStreamFinished: ghProc.errText = text - } - onExited: (exitCode, exitStatus) => { - ghProc.exitSeen = true; - ghProc.lastExit = exitCode; - } - onRunningChanged: { - if (running) { - body = ""; - errText = ""; - exitSeen = false; - lastExit = 0; - timedOut = false; - abandoned = false; - timeoutText = ""; - return; - } - ghWatchdog.stop(); - if (abandoned) { - // The watchdog already settled this job as timed out. - abandoned = false; - root.pump(); - return; - } - // A terminated run's partial output is not a response, and its - // exit status is whatever the signal left — it may even read as 0. - if (timedOut) - root.settle(Helpers.GH_TIMEOUT_EXIT, "", timeoutText); - else - root.settle(exitSeen ? lastExit : ProcHelpers.NOT_STARTED, body, errText); - } - } - - Timer { - id: ghWatchdog - interval: Helpers.GH_TIMEOUT_MS - onTriggered: root.ghWatchdogFired() + killGraceMs: Helpers.GH_KILL_GRACE_MS + onCompleted: (code, body, error) => root.settle(code, body, error) + onAvailable: root.pump() } Timer { diff --git a/roles/desktop/files/quickshell/Common/GitHubQueue.js b/roles/desktop/files/quickshell/Common/GitHubQueue.js new file mode 100644 index 00000000..1bcf0240 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/GitHubQueue.js @@ -0,0 +1,39 @@ +// Pure scheduling policy for gh jobs. No processes, timers or domain cache. +// Interactive work stays FIFO ahead of background polling; duplicate reads +// retain their richer original payload while being promoted when requested. +function jobKey(job) { + switch (job.kind) { + case "watch": return "watch:" + job.slug; + case "commits": return "commits:" + job.slug; + case "stats": return "stats:" + job.sha; + case "runs": return "runs:" + job.slug + ":" + job.generation; + case "events": return "events:" + job.slug + ":" + job.generation; + case "notifications": return "notifications:" + job.generation; + default: return job.kind; + } +} + +function enqueue(queue, active, job) { + const key = jobKey(job); + if (active !== null && jobKey(active) === key) + return { queue: queue, added: false }; + const queuedAt = queue.findIndex(queued => jobKey(queued) === key); + let next; + let remaining = queue; + if (queuedAt >= 0) { + if (job.interactive !== true || queue[queuedAt].interactive) + return { queue: queue, added: false }; + next = Object.assign({}, queue[queuedAt], { interactive: true }); + remaining = queue.slice(0, queuedAt).concat(queue.slice(queuedAt + 1)); + } else { + next = Object.assign({}, job, { interactive: job.interactive === true }); + } + let at = next.interactive ? remaining.findIndex(queued => !queued.interactive) : -1; + if (at < 0) + at = remaining.length; + return { queue: remaining.slice(0, at).concat([next], remaining.slice(at)), + added: queuedAt < 0 }; +} + +if (typeof module !== "undefined" && module.exports) + module.exports = { jobKey: jobKey, enqueue: enqueue }; diff --git a/roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml b/roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml new file mode 100644 index 00000000..0377e637 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml @@ -0,0 +1,50 @@ +import QtQuick +import "../SettingsHelpers.js" as Helpers + +// The production document state is independent of FileView/Process so the +// same asynchronous transitions run under QtTest and the desktop engine. +QtObject { + id: document + property var values: ({}) + property var context: ({}) + property var source: ({}) + property var explicitKeys: [] + property string baseline: "" + property string submitted: "" + property bool busy: false + + function text() { + return Helpers.serializeDocument(values, source, explicitKeys); + } + + function begin() { + if (busy) + return false; + submitted = text(); + busy = true; + return true; + } + + function complete(committed) { + // A second UI change may arrive while fsync or another writer holds + // the lock. Rebase only that pending change onto the committed result. + const before = Helpers.parse(submitted).value || ({}); + const pending = Helpers.parse(text()).value || ({}); + const saved = Helpers.parse(committed); + if (saved.status !== "ok") + throw new Error("Settings writer returned an invalid document"); + const rebased = Helpers.rebaseDocuments(before, pending, saved.value); + baseline = committed; + source = saved.value; + explicitKeys = Helpers.overrideKeys(rebased); + submitted = ""; + busy = false; + return { values: Helpers.merge(rebased, context), + pending: JSON.stringify(rebased) !== JSON.stringify(saved.value) }; + } + + function abandon() { + submitted = ""; + busy = false; + } +} diff --git a/roles/desktop/files/quickshell/Common/Persistence/qmldir b/roles/desktop/files/quickshell/Common/Persistence/qmldir new file mode 100644 index 00000000..fbbe9153 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/Persistence/qmldir @@ -0,0 +1 @@ +SettingsDocument SettingsDocument.qml diff --git a/roles/desktop/files/quickshell/Common/Settings.qml b/roles/desktop/files/quickshell/Common/Settings.qml index 9240fc62..e1b1ecab 100644 --- a/roles/desktop/files/quickshell/Common/Settings.qml +++ b/roles/desktop/files/quickshell/Common/Settings.qml @@ -5,6 +5,7 @@ import Quickshell.Io import Quickshell.Services.Notifications import "SettingsHelpers.js" as SettingsHelpers import "ProcHelpers.js" as ProcHelpers +import "Persistence" // Shell settings store (design v2, "Shell settings"). Single source of truth // for user-tunable shell configuration: merged over defaults on load, @@ -135,12 +136,13 @@ Singleton { // Change counter for dirty-state bindings; see scheduleSave(). property int revision: 0 property bool migrationPending: false - property bool writeInFlight: false - property string writeSnapshot: "" - property string lastPersistedText: "" - // What FileView compares the next setText against: the bytes it last - // read or tried to write, which after a failed save is not the file. - // See saveNow(). + property alias writeInFlight: document.busy + property alias writeSnapshot: document.submitted + property alias lastPersistedText: document.baseline + property alias sourceDocument: document.source + property alias explicitOverrides: document.explicitKeys + property var resetOverrides: [] + // Last observed file bytes, retained for read-error diagnostics. property string storeText: "" // A reload that came due while a write was in flight; see reloadStore(). property bool reloadAfterWrite: false @@ -187,11 +189,13 @@ Singleton { { id: "network", group: "Devices", label: "Network", glyph: "wifi", description: "Connections, IP addresses and DNS", system: true }, { id: "touchpad", group: "Devices", label: "Touchpad", glyph: "mouse", - description: "Scrolling" }, + description: "Tap, natural scrolling and sensitivity", system: true }, + { id: "keyboard", group: "Devices", label: "Keyboard", glyph: "keyboard", + description: "Layouts and layout switching", system: true }, { id: "power", group: "System", label: "Power", glyph: "power", description: "Screen off, lock, suspend and stay awake" }, { id: "region", group: "System", label: "Region & formats", glyph: "language", - description: "Clock and temperature formats" }, + description: "Timezone, language, clock and temperature formats" }, { id: "accounts", group: "System", label: "Online accounts", glyph: "account_circle", description: "Connected accounts and calendar access", system: true }, { id: "plugins", group: "System", label: "Omarchy plugins", glyph: "extension", @@ -319,9 +323,22 @@ Singleton { resetLabel = ""; } + function markExplicit(key) { + if (!Object.prototype.hasOwnProperty.call(defaults, key)) + return false; + migrationPending = false; + if (explicitOverrides.indexOf(key) === -1) + explicitOverrides = explicitOverrides.concat([key]); + // Selecting the current default is still an explicit choice, even + // when QML emits no property change signal for its equal value. + scheduleSave(); + return true; + } + function set(key, value) { + if (!markExplicit(key)) + return; clearUndo(); - migrationPending = false; root[key] = SettingsHelpers.normalizeKey(key, value); } @@ -332,7 +349,7 @@ Singleton { function setModuleEnabled(id, on) { clearUndo(); - migrationPending = false; + markExplicit("mods"); const next = { left: [], center: [], right: [] }; for (const col of ["left", "center", "right"]) next[col] = mods[col].map(m => m.id === id @@ -342,7 +359,7 @@ Singleton { function setModuleDetail(id, detail) { clearUndo(); - migrationPending = false; + markExplicit("mods"); const next = { left: [], center: [], right: [] }; for (const col of ["left", "center", "right"]) next[col] = mods[col].map(m => m.id === id @@ -358,7 +375,7 @@ Singleton { function setModuleOptions(id, changes) { clearUndo(); - migrationPending = false; + markExplicit("modOpts"); const next = SettingsHelpers.clone(modOpts); for (const key of Object.keys(changes || ({}))) next[id][key] = changes[key]; @@ -367,20 +384,20 @@ Singleton { function setModuleOrder(left, center, right) { clearUndo(); - migrationPending = false; + markExplicit("mods"); mods = SettingsHelpers.normalizeMods({ left: left, center: center, right: right }); } function setDrawerTabEnabled(id, on) { clearUndo(); - migrationPending = false; + markExplicit("drawerTabs"); drawerTabs = SettingsHelpers.normalizeDrawerTabs(drawerTabs.map(tab => tab.id === id ? ({ id: tab.id, on: on }) : tab)); } function setDrawerTabOrder(ids) { clearUndo(); - migrationPending = false; + markExplicit("drawerTabs"); const held = {}; for (const tab of drawerTabs) held[tab.id] = tab.on; @@ -390,7 +407,7 @@ Singleton { function setDrawerOverviewKey(key, on) { clearUndo(); - migrationPending = false; + markExplicit("drawerOverview"); const next = SettingsHelpers.clone(drawerOverview); next[key] = on; drawerOverview = SettingsHelpers.normalizeDrawerOverview(next); @@ -410,6 +427,8 @@ Singleton { const enabled = modulePresetIds(name); clearUndo(); migrationPending = false; + resetOverrides = explicitOverrides.slice(); + markExplicit("mods"); resetSnapshot = { mods: SettingsHelpers.clone(mods) }; resetLabel = "Widget profile"; const next = { left: [], center: [], right: [] }; @@ -427,6 +446,8 @@ Singleton { function resetKeys(keys, label) { migrationPending = false; + resetOverrides = explicitOverrides.slice(); + explicitOverrides = explicitOverrides.filter(key => keys.indexOf(key) === -1); const previous = {}; for (const key of keys) previous[key] = SettingsHelpers.clone(root[key]); @@ -436,6 +457,7 @@ Singleton { root[key] = key === "mods" ? SettingsHelpers.clone(defaults.mods) : key === "modOpts" ? SettingsHelpers.defaultModOpts() : defaults[key]; + scheduleSave(); announcement = resetLabel + " reset. Undo available for eight seconds."; resetTimer.restart(); } @@ -449,6 +471,9 @@ Singleton { return; } migrationPending = false; + resetOverrides = explicitOverrides.slice(); + markExplicit("mods"); + markExplicit("modOpts"); resetSnapshot = { mods: SettingsHelpers.clone(mods), modOpts: SettingsHelpers.clone(modOpts) }; resetLabel = label || "Widget"; const next = { left: [], center: [], right: [] }; @@ -460,6 +485,7 @@ Singleton { if (options[id] !== undefined) options[id] = SettingsHelpers.clone(defaults.modOpts[id]); modOpts = SettingsHelpers.normalizeModOpts(options); + scheduleSave(); announcement = resetLabel + " reset. Undo available for eight seconds."; resetTimer.restart(); } @@ -487,12 +513,14 @@ Singleton { if (!resetSnapshot) return; const previous = resetSnapshot; + explicitOverrides = resetOverrides.slice(); resetTimer.stop(); for (const key of Object.keys(previous)) root[key] = SettingsHelpers.clone(previous[key]); resetSnapshot = null; const label = resetLabel; resetLabel = ""; + scheduleSave(); announcement = label + " restored."; } @@ -518,6 +546,16 @@ Singleton { return out; } + SettingsDocument { + id: document + values: root.snapshot() + context: ({ connectedWidgets: root.connectedWidgetsConfigured }) + } + + function documentText() { + return document.text(); + } + // One-time migration of the old QS_WEATHER_* env configuration: only a // file that predates modOpts (or no file at all) takes the env values; // after the first save modOpts exists on disk and the seed never re-fires. @@ -604,7 +642,22 @@ Singleton { } if (newerSchema) protectNewerFile(result.value.v); - const parsed = result.value; + let parsed = result.value; + if (loaded && savePending && !newerSchema && result.status === "ok") { + try { + parsed = SettingsHelpers.rebaseDocuments(sourceDocument, + SettingsHelpers.parse(documentText()).value, parsed); + } catch (error) { + // The authoritative writer will refuse the conflict and keep + // the pending candidate in a recoverable sidecar before the + // external values replace the form. Keep this base unchanged. + announcement = String(error); + reloadAfterWrite = true; + saveTimer.restart(); + return; + } + } + const retainPending = savePending; const previousText = lastPersistedText; if (result.status !== "corrupt") lastPersistedText = rawText; @@ -615,6 +668,8 @@ Singleton { ready = true; return; } + sourceDocument = result.value || ({}); + explicitOverrides = SettingsHelpers.overrideKeys(parsed); const merged = SettingsHelpers.merge(parsed, { connectedWidgets: root.connectedWidgetsConfigured }); if (loaded && SettingsHelpers.serialize(merged) === SettingsHelpers.serialize(snapshot())) { @@ -633,13 +688,21 @@ Singleton { // The one key that is not a straight copy: a file predating modOpts // (or no file at all) still takes the retired QS_WEATHER_* env // configuration on its way in. - assignChanged("modOpts", seedWeatherFromEnv(parsed, merged.modOpts)); + const seededOptions = seedWeatherFromEnv(parsed, merged.modOpts); + assignChanged("modOpts", seededOptions); + if (JSON.stringify(seededOptions) !== JSON.stringify(merged.modOpts) + && explicitOverrides.indexOf("modOpts") === -1) + explicitOverrides = explicitOverrides.concat(["modOpts"]); ready = true; migrationPending = parsed !== null && parsed.v !== SettingsHelpers.VERSION; firstRun = result.status === "empty"; loaded = true; applyScrollFactor(); applyGlassEffect(); + if (retainPending && !newerSchema) { + migrationPending = false; + scheduleSave(); + } } // After a rollback to an older shell the file carries keys and option @@ -708,26 +771,27 @@ Singleton { } } - function handleSaveSucceeded() { - const completedSnapshot = writeSnapshot; - lastPersistedText = completedSnapshot; - storeText = completedSnapshot; + function handleSaveSucceeded(committed) { const wasRetry = saveError; + const state = document.complete(committed || writeSnapshot); + storeText = lastPersistedText; + ready = false; + for (const key of Object.keys(root.defaults)) + assignChanged(key, state.values[key]); + ready = true; releaseWriteGuard(); saveError = false; lastSavedAt = Date.now(); - const changedWhileSaving = !sameContent(SettingsHelpers.serialize(snapshot()), - completedSnapshot); - savePending = changedWhileSaving; + savePending = state.pending; if (wasRetry) announcement = "Settings saved."; - if (changedWhileSaving) + if (state.pending) saveTimer.restart(); } function handleSaveFailure(error) { - // FileView keeps the attempted bytes even though they never reached - // the file; saveNow() has to write around them. + // Keep the pending document available for retry. The transaction + // helper preserves the old file when publication fails. storeText = writeSnapshot; releaseWriteGuard(); savePending = false; @@ -740,7 +804,7 @@ Singleton { if (!ready || migrationPending || corruptBackupPending || loadError || writeInFlight) return; - const next = SettingsHelpers.serialize(snapshot()); + const next = documentText(); // Already on disk: settle without writing. That includes a failed // save whose change was undone before Retry — the atomic write left // the previous file in place. @@ -752,23 +816,38 @@ Singleton { } return; } - // FileView.setText compares against the bytes the view last read or - // tried to write, not against the file, and skips a match without - // emitting saved or saveFailed. After a failed save that is the - // attempt itself, so a Retry of the same content would hold the write - // guard for the rest of the session. The same JSON with one more - // trailing newline makes it a real write. - writeSnapshot = next === storeText ? next + "\n" : next; - writeInFlight = true; - try { - // Completion arrives only through saved/saveFailed. Quickshell - // logs a failed atomic commit (the fsync or the rename) and still - // emits saved, so saved means the bytes were written, not that - // they replaced the file. - store.setText(writeSnapshot); - } catch (error) { - handleSaveFailure(FileViewError.Unknown); - console.warn("settings save threw:", error); + if (!document.begin()) + return; + settingsWriter.inputText = JSON.stringify({ baseline: lastPersistedText, + candidate: writeSnapshot, version: SettingsHelpers.VERSION }) + "\n"; + settingsWriter.running = true; + } + + CommandRequest { + id: settingsWriter + command: ["python3", "-B", Quickshell.shellDir + "/scripts/settings-store", root.filePath] + stdinEnabled: true + timeoutMs: 10000 + timeoutMessage: "Saving settings timed out. Your previous file is intact." + onCompleted: (code, body, error) => { + let result = {}; + try { result = JSON.parse(body); } catch (_) {} + if (code === 0 && result.ok && typeof result.text === "string") { + try { + root.handleSaveSucceeded(result.text); + return; + } catch (failure) { + result.error = String(failure); + } + } + root.handleSaveFailure(FileViewError.Unknown); + root.announcement = result.error || error || "Could not save settings. Retry is available."; + if (result.error && result.error.indexOf("Your pending edit is saved at ") !== -1) { + // The rejected edit is retained byte-for-byte in its sidecar; + // refresh the form so Retry cannot repeat the same conflict. + root.saveError = false; + store.reload(); + } } } @@ -1038,10 +1117,9 @@ Singleton { id: store path: root.filePath printErrors: false + // Reads/watch notifications only. The settings-store process verifies + // merge, fsync and atomic publication before reporting save success. atomicWrites: true - // The atomic write syncs to disk before its rename, which can take - // seconds under heavy IO; off the GUI thread the shell keeps drawing - // meanwhile. saveNow() never starts a write under another one. blockWrites: false blockLoading: true watchChanges: true @@ -1055,8 +1133,6 @@ Singleton { } onLoaded: root.applyLoaded(text()) onLoadFailed: error => root.handleLoadFailure(error) - onSaved: root.handleSaveSucceeded() - onSaveFailed: error => root.handleSaveFailure(error) } // Force the load to complete during singleton construction so the first diff --git a/roles/desktop/files/quickshell/Common/SettingsHelpers.js b/roles/desktop/files/quickshell/Common/SettingsHelpers.js index 22d6a0cc..6f7213b9 100644 --- a/roles/desktop/files/quickshell/Common/SettingsHelpers.js +++ b/roles/desktop/files/quickshell/Common/SettingsHelpers.js @@ -1,7 +1,7 @@ // Pure settings-schema helpers shared by QML and Node tests. // Keep this file free of Qt APIs so persistence stays deterministic. -var VERSION = 26; +var VERSION = 27; var BAR_STYLES = ["hug", "floating", "attached"]; var PALETTE_MODES = ["wallpaper", "fixed"]; @@ -980,18 +980,7 @@ function migrateModOpts(raw, sourceVersion, rawSettings) { next.indicators.order.splice(recordingIndex === -1 ? next.indicators.order.length : recordingIndex + 1, 0, "ocr"); - var priorIds = INDICATOR_ACTION_IDS.filter(function(id) { - return id !== "ocr"; - }); - var priorEnabled = Array.isArray(rawIndicators.enabled) - && priorIds.every(function(id) { - return rawIndicators.enabled.indexOf(id) !== -1; - }); - if (priorEnabled) { - var enabledRecordingIndex = next.indicators.enabled.indexOf("recording"); - next.indicators.enabled.splice(enabledRecordingIndex === -1 - ? next.indicators.enabled.length : enabledRecordingIndex + 1, 0, "ocr"); - } + } return next; } @@ -1134,145 +1123,20 @@ function migrateMods(raw, sourceVersion, context) { return normalizeMods(migrated); } -// Schema 4 is the glass menubar: a taller bar, full-radius corners, a wider -// floating gap and the design's accent. A settings file written by schema 3 -// carries the old geometry for every one of those keys, so loading it as-is -// would silently keep the previous design's proportions. -// -// Only values the user never moved are adopted — a key still holding its -// schema-3 default takes the schema-4 one, anything else is theirs and stays. -var V3_DEFAULTS = { - barHeight: 30, barRadius: 9, gap: 8, accent: "#9ecbeb", font: "oppo", - osd: "top" -}; - -var V3_MOD_OPT_DEFAULTS = { - ws: { style: "numbers" }, - media: { maxWidth: 220 }, - clock: { dateFormat: "ddd dd" } -}; - -function adoptRedesign(parsed) { - if (!parsed || typeof parsed !== "object" - || (typeof parsed.v === "number" && parsed.v >= 4)) - return parsed; - var next = clone(parsed); - Object.keys(V3_DEFAULTS).forEach(function(key) { - if (next[key] === V3_DEFAULTS[key]) - delete next[key]; - }); - if (next.modOpts && typeof next.modOpts === "object") { - Object.keys(V3_MOD_OPT_DEFAULTS).forEach(function(id) { - var entry = next.modOpts[id]; - if (!entry || typeof entry !== "object") - return; - Object.keys(V3_MOD_OPT_DEFAULTS[id]).forEach(function(key) { - if (entry[key] === V3_MOD_OPT_DEFAULTS[id][key]) - delete entry[key]; - }); - }); - } - return next; -} - -// Schema 7 makes the softer variable face the shell default. As with the -// schema-4 redesign, a stored value equal to the previous default is treated -// as untouched; every other valid font remains an explicit user choice. -// A missing font must use today's default, including unversioned installer -// seeds. It is not evidence of a saved schema-6 font preference. -function adoptSofterTypography(parsed) { - if (!parsed || typeof parsed !== "object" - || (typeof parsed.v === "number" && parsed.v >= 7)) - return parsed; - var next = clone(parsed); - if (next.font === "urbanist") - next.font = "google"; - return next; -} - -// Schema 10 restores the compact August menubar shown in the repository's -// desktop screenshot, without reviving its fixed layout. As with the earlier -// redesign migration, only values still equal to schema 9's defaults move to -// the new visual baseline; customized geometry, glass, colors and workspace -// presentation remain the user's choices. -var V9_CLASSIC_DEFAULTS = { - glassEnabled: true, - barHeight: 46, - barRadius: 23, - gap: 10, - accent: "#5e9bff" -}; - -var V9_CLASSIC_MOD_OPT_DEFAULTS = { - ws: { style: "dots" }, - clock: { dateFormat: "ddd d MMM" } -}; - -function adoptClassicMenubar(parsed) { - if (!parsed || typeof parsed !== "object" - || (typeof parsed.v === "number" && parsed.v >= 10)) - return parsed; - // Schema 3 and unversioned files first pass through adoptRedesign(), - // which already removes their untouched defaults. Do not then mistake a - // deliberate old-style value for schema 9's default on the second hop. - if (typeof parsed.v !== "number" || parsed.v < 4) - return parsed; - var next = clone(parsed); - var untouchedCustomColor = (next.barColorMode === undefined - || next.barColorMode === "default") - && next.barCustomHue === 247 - && next.barCustomSaturation === 29 - && next.barCustomLightness === 11; - if (untouchedCustomColor) { - delete next.barCustomHue; - delete next.barCustomSaturation; - delete next.barCustomLightness; - } - Object.keys(V9_CLASSIC_DEFAULTS).forEach(function(key) { - if (next[key] === V9_CLASSIC_DEFAULTS[key]) - delete next[key]; - }); - if (next.modOpts && typeof next.modOpts === "object") { - Object.keys(V9_CLASSIC_MOD_OPT_DEFAULTS).forEach(function(id) { - var entry = next.modOpts[id]; - if (!entry || typeof entry !== "object") - return; - Object.keys(V9_CLASSIC_MOD_OPT_DEFAULTS[id]).forEach(function(key) { - if (entry[key] === V9_CLASSIC_MOD_OPT_DEFAULTS[id][key]) - delete entry[key]; - }); - }); - } - return next; -} - -// Schema 6 replaces two booleans with explicit visual modes. A v5 floating -// bar whose geometry was never changed becomes the new edge-hugging default; -// customized floating geometry remains floating, and the old edge-to-edge -// option remains attached. The fixed color values are intentionally never -// discarded: paletteMode only chooses which palette is active. -var V5_DEFAULTS = { - barHeight: 46, - barRadius: 23, - gap: 10, - accent: "#5e9bff", - barColorMode: "default", - barCustomHue: 247, - barCustomSaturation: 29, - barCustomLightness: 11 -}; +// Stored legacy values are explicit choices, even when equal to an old +// default. Visual redesigns must never infer ownership from value equality. +// Structural migration keeps the old mode and every explicit value. A +// legacy file with geometry/color fields but no mode retains its old mode; +// a sparse file with neither follows today's default. function migrateBarStyle(parsed, defaultsValue) { if (typeof parsed.v === "number" && parsed.v >= 6) return enumIn(parsed.barStyle, BAR_STYLES, defaultsValue); if (parsed.floating === false) return "attached"; - var pristine = intIn(parsed.barHeight, 28, 60, 1, V5_DEFAULTS.barHeight) - === V5_DEFAULTS.barHeight - && intIn(parsed.barRadius, 0, 30, 1, V5_DEFAULTS.barRadius) - === V5_DEFAULTS.barRadius - && intIn(parsed.gap, 4, 24, 1, V5_DEFAULTS.gap) === V5_DEFAULTS.gap; - return pristine ? "hug" : "floating"; + return parsed.floating === true || ["barHeight", "barRadius", "gap"].some(function(key) { + return Object.prototype.hasOwnProperty.call(parsed, key); + }) ? "floating" : defaultsValue; } function migratePaletteMode(parsed, defaultsValue) { @@ -1280,30 +1144,27 @@ function migratePaletteMode(parsed, defaultsValue) { return enumIn(parsed.paletteMode, PALETTE_MODES, defaultsValue); if (parsed.accentWall === true) return "wallpaper"; - var accent = hexIn(parsed.accent, V5_DEFAULTS.accent).toLowerCase(); - var barMode = enumIn(parsed.barColorMode, BAR_COLOR_IDS, - V5_DEFAULTS.barColorMode); - var customHue = intIn(parsed.barCustomHue, 0, 359, 1, - V5_DEFAULTS.barCustomHue); - var customSaturation = intIn(parsed.barCustomSaturation, 0, 100, 1, - V5_DEFAULTS.barCustomSaturation); - var customLightness = intIn(parsed.barCustomLightness, 0, 100, 1, - V5_DEFAULTS.barCustomLightness); - return accent === V5_DEFAULTS.accent && barMode === V5_DEFAULTS.barColorMode - && customHue === V5_DEFAULTS.barCustomHue - && customSaturation === V5_DEFAULTS.barCustomSaturation - && customLightness === V5_DEFAULTS.barCustomLightness - ? "wallpaper" : "fixed"; + return parsed.accentWall === false || ["accent", "barColorMode", "barCustomHue", + "barCustomSaturation", "barCustomLightness"].some(function(key) { + return Object.prototype.hasOwnProperty.call(parsed, key); + }) ? "fixed" : defaultsValue; } // `context.connectedWidgets` is the install's connected-service feature; it // decides whether a widget that schema migration adds starts on. function merge(raw, context) { var d = defaults(); + if (context && context.connectedWidgets) { + ["left", "center", "right"].forEach(function(column) { + d.mods[column].forEach(function(entry) { + if (["modelusage", "gh", "t3", "hermes"].indexOf(entry.id) !== -1) + entry.on = true; + }); + }); + } if (!raw || typeof raw !== "object") return d; - var parsed = adoptClassicMenubar( - adoptSofterTypography(adoptRedesign(raw))); + var parsed = raw; var idleMode = enumIn(parsed.idleInhibitMode, ["off", "30m", "1h", "unplugged", "always"], parsed.idleInhibited === true ? "always" : d.idleInhibitMode); @@ -1387,7 +1248,7 @@ function merge(raw, context) { drawerOverview: normalizeDrawerOverview(parsed.drawerOverview), drawerHover: enumIn(parsed.drawerHover, DRAWER_HOVER_MODES, d.drawerHover), drawerWidth: intIn(parsed.drawerWidth, 320, 480, 10, d.drawerWidth), - mods: migrateMods(parsed.mods, parsed.v, context), + mods: parsed.mods === undefined ? d.mods : migrateMods(parsed.mods, parsed.v, context), modOpts: migrateModOpts(parsed.modOpts, parsed.v, parsed) }; } @@ -1499,6 +1360,131 @@ function serialize(settings) { return JSON.stringify(ordered, null, 2) + "\n"; } +// Presence is ownership: a value explicitly set to today's default remains +// an override. New files are sparse; every stored legacy key is conservatively +// adopted as explicit. Unknown fields are carried through without validation. +function overrideKeys(raw) { + var known = defaults(); + return Object.keys(raw || {}).filter(function(key) { + return Object.prototype.hasOwnProperty.call(known, key) + && JSON.stringify(unknownFields(raw[key], known[key])) !== JSON.stringify(raw[key]); + }); +} + +function unknownFields(original, schema) { + if (!original || typeof original !== "object") + return undefined; + if (Array.isArray(original)) { + if (!Array.isArray(schema)) + return undefined; + var entries = []; + original.forEach(function(item) { + if (!item || typeof item.id !== "string") + return; + var known = schema.find(function(value) { return value && value.id === item.id; }); + if (!known) { + entries.push(clone(item)); + return; + } + var extra = unknownFields(item, known); + if (extra) { + extra.id = item.id; + entries.push(extra); + } + }); + return entries.length ? entries : undefined; + } + if (!schema || typeof schema !== "object") + return undefined; + var out = {}; + Object.keys(original).forEach(function(key) { + if (key === "__proto__" || key === "constructor" || key === "prototype") + return; + var value = Object.prototype.hasOwnProperty.call(schema, key) + ? unknownFields(original[key], schema[key]) : clone(original[key]); + if (value !== undefined) + out[key] = value; + }); + return Object.keys(out).length ? out : undefined; +} + +function preserveUnknown(original, replacement) { + if (Array.isArray(replacement)) { + if (!Array.isArray(original)) + return clone(replacement); + // Layout entries carry stable ids. Preserve future entries and fields + // while retaining the current user's ordering of understood entries. + if (replacement.every(function(item) { return item && typeof item.id === "string"; })) { + var result = replacement.map(function(item) { + return preserveUnknown(original.find(function(old) { + return old && old.id === item.id; + }), item); + }); + original.forEach(function(item) { + if (item && typeof item.id === "string" + && !replacement.some(function(next) { return next.id === item.id; })) + result.push(clone(item)); + }); + return result; + } + return clone(replacement); + } + if (!replacement || typeof replacement !== "object") + return clone(replacement); + var out = original && typeof original === "object" && !Array.isArray(original) + ? clone(original) : {}; + Object.keys(replacement).forEach(function(key) { + if (key !== "__proto__" && key !== "constructor" && key !== "prototype") + out[key] = preserveUnknown(out[key], replacement[key]); + }); + return out; +} + +function rebaseDocuments(base, desired, current, path) { + if (JSON.stringify(desired) === JSON.stringify(base) + || JSON.stringify(desired) === JSON.stringify(current)) + return current; + if (JSON.stringify(current) === JSON.stringify(base)) + return desired; + if (base && desired && current && [base, desired, current].every(function(value) { + return typeof value === "object" && !Array.isArray(value); + })) { + var out = clone(current); + Object.keys(base).concat(Object.keys(desired)).forEach(function(key) { + if (key === "__proto__" || key === "constructor" || key === "prototype") + return; + if (!path && key === "v") { + out.v = desired.v; + return; + } + var result = rebaseDocuments(base[key], desired[key], current[key], (path || "") + "/" + key); + if (result === undefined) + delete out[key]; + else + out[key] = result; + }); + return out; + } + throw new Error("Another writer changed " + (path || "/") + "; reload before retrying"); +} + +function serializeDocument(settings, original, explicitKeys) { + var out = clone(original || {}); + out.v = VERSION; + Object.keys(defaults()).forEach(function(key) { + if (explicitKeys.indexOf(key) !== -1) + out[key] = preserveUnknown(out[key], settings[key]); + else { + var extra = unknownFields(out[key], defaults()[key]); + if (extra === undefined) + delete out[key]; + else + out[key] = extra; + } + }); + return JSON.stringify(out, null, 2) + "\n"; +} + // Distinguishing "no settings yet" from "settings we could not read" is what // keeps a corrupt file recoverable: both merge to defaults, but only the empty // case may be overwritten. Returns { status, value } where status is one of: @@ -1591,6 +1577,11 @@ var exported = { normalizeKey: normalizeKey, isNewerSchema: isNewerSchema, serialize: serialize, + overrideKeys: overrideKeys, + preserveUnknown: preserveUnknown, + unknownFields: unknownFields, + serializeDocument: serializeDocument, + rebaseDocuments: rebaseDocuments, parse: parse }; diff --git a/roles/desktop/files/quickshell/Common/SettingsSearchData.js b/roles/desktop/files/quickshell/Common/SettingsSearchData.js index b9352979..c8f0f7cd 100644 --- a/roles/desktop/files/quickshell/Common/SettingsSearchData.js +++ b/roles/desktop/files/quickshell/Common/SettingsSearchData.js @@ -9,6 +9,13 @@ // against SettingsHelpers' schema under Node. var ROWS = [ + { page: "keyboard", pageLabel: "Keyboard", group: "Keyboard layouts", label: "Keyboard layouts", key: "", terms: "input language variant qwerty azerty dvorak colemak" }, + { page: "keyboard", pageLabel: "Keyboard", group: "Switch layouts", label: "Layout switching", key: "", terms: "keyboard shortcut caps lock alt shift next layout" }, + { page: "touchpad", pageLabel: "Touchpad", group: "Touchpad", label: "Tap to click", key: "", terms: "touchpad tapping click input" }, + { page: "touchpad", pageLabel: "Touchpad", group: "Touchpad", label: "Natural scrolling", key: "", terms: "touchpad reverse scroll direction" }, + { page: "touchpad", pageLabel: "Touchpad", group: "Touchpad", label: "Pointer sensitivity", key: "", terms: "touchpad mouse speed acceleration" }, + { page: "region", pageLabel: "Region & formats", group: "System timezone", label: "Timezone", key: "", terms: "time zone city country daylight saving clock" }, + { page: "region", pageLabel: "Region & formats", group: "System language", label: "System language", key: "", terms: "locale language region lang regional formats" }, // Appearance { page: "network", pageLabel: "Network", group: "Connections", label: "Network connections", key: "", terms: "wifi ethernet saved profile adapter vpn advanced editor" }, { page: "network", pageLabel: "Network", group: "Connection", label: "Autoconnect and metered", key: "", terms: "automatic join metered data limit background" }, diff --git a/roles/desktop/files/quickshell/Common/SystemSettings.qml b/roles/desktop/files/quickshell/Common/SystemSettings.qml index 71ff9355..42098957 100644 --- a/roles/desktop/files/quickshell/Common/SystemSettings.qml +++ b/roles/desktop/files/quickshell/Common/SystemSettings.qml @@ -27,6 +27,8 @@ Singleton { } readonly property SystemSettingsBackend sound: SystemSettingsBackend { domain: "sound" } readonly property SystemSettingsBackend network: SystemSettingsBackend { domain: "network" } + readonly property SystemSettingsBackend input: SystemSettingsBackend { domain: "input" } + readonly property SystemSettingsBackend region: SystemSettingsBackend { domain: "region" } readonly property SystemSettingsBackend accounts: SystemSettingsBackend { domain: "accounts" onCompleted: result => { if (result.success) Calendar.refreshDefault(); } diff --git a/roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml b/roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml index 29ea4879..8bf2e88f 100644 --- a/roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml +++ b/roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml @@ -40,7 +40,9 @@ Item { if (watchers && !busy) snapshotProc.running = true; } - onWatchersChanged: monitor.running = watchers > 0 + // Input is polled: no extra compositor socket reader lives beyond the page. + readonly property bool hasMonitor: domain !== "input" && domain !== "region" + onWatchersChanged: monitor.running = watchers > 0 && hasMonitor function run(value) { if (busy) return false; @@ -76,7 +78,7 @@ Item { repeat: true onTriggered: { root.refresh(); - if (!monitor.running) + if (root.hasMonitor && !monitor.running) monitor.running = true; } } diff --git a/roles/desktop/files/quickshell/Common/T3Actions.qml b/roles/desktop/files/quickshell/Common/T3Actions.qml new file mode 100644 index 00000000..f26b1984 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/T3Actions.qml @@ -0,0 +1,385 @@ +pragma Singleton +import QtQuick +import Quickshell +import "T3CodeHelpers.js" as Helpers + +// Domain commands and their per-action feedback. T3Rpc owns wire correlation; +// views continue using its facade, so this boundary changes no public API. +// A command is never retried on transport loss or after partial batch success. +Singleton { + id: root + + // Per-command UI state, keyed by actionKey(kind, threadId, requestId): + // { pending, error, commandId, ... }. A finished entry is left in place so + // the button can keep showing why it failed, which is why the deadline + // sweep below only ever looks at pending ones. + property var actionStates: ({}) + readonly property int actionTimeoutMs: 15000 + + // Optional-capability gates for the lifecycle commands below. Derived from + // the connection here — where the commands live — and re-exported by + // T3Code; a missing key means an older server, so the command is never + // sent under version skew. + readonly property bool supportsSettlement: + T3Connection.environmentCapabilities.threadSettlement === true + readonly property bool supportsSnooze: + T3Connection.environmentCapabilities.threadSnooze === true + readonly property bool supportsTitleRegeneration: + T3Connection.environmentCapabilities.threadTitleRegeneration === true + readonly property bool supportsPinning: + T3Connection.environmentCapabilities.threadPinning === true + + // ---- commands and action state --------------------------------------- + + function actionKey(kind, threadId, requestId) { + return kind + "|" + threadId + "|" + (requestId ?? ""); + } + + function actionState(kind, threadId, requestId) { + const states = actionStates; + return states[actionKey(kind, threadId, requestId)] ?? null; + } + + function actionPending(kind, threadId, requestId) { + const current = actionState(kind, threadId, requestId); + return current !== null && current.pending === true; + } + + function actionError(kind, threadId, requestId) { + const current = actionState(kind, threadId, requestId); + return current && typeof current.error === "string" ? current.error : ""; + } + + function putActionState(key, value) { + const next = Object.assign({}, actionStates); + if (value === null) + delete next[key]; + else + next[key] = value; + actionStates = next; + } + + function beginAction(key, commandId, awaitResolution, timeoutMs) { + const state = { + pending: true, + error: "", + commandId: commandId, + awaitResolution: awaitResolution === true, + startedAt: Date.now() + }; + if (typeof timeoutMs === "number" && timeoutMs > 0) + state.timeoutMs = timeoutMs; + putActionState(key, state); + } + + function failAllPendingActions(message) { + const next = Object.assign({}, actionStates); + let changed = false; + for (const key in next) { + if (!next[key] || next[key].pending !== true) + continue; + next[key] = Object.assign({}, next[key], { + pending: false, + error: message || "Disconnected before confirmation" + }); + changed = true; + } + if (changed) + actionStates = next; + } + + function failAction(key, message) { + const current = actionStates[key]; + if (!current) + return; + putActionState(key, Object.assign({}, current, { + pending: false, + error: message || "Action failed" + })); + } + + function clearAction(key) { + if (actionStates[key] !== undefined) + putActionState(key, null); + } + + + + + + function rejectAction(key, message, awaitResolution) { + putActionState(key, { + pending: false, + error: message, + commandId: "", + awaitResolution: awaitResolution === true, + startedAt: Date.now() + }); + return ""; + } + + // Dispatch commands one at a time. A later command is never attempted + // after an earlier rejection, and reconnecting never replays the batch. + function dispatchBatch(commands, key, options) { + const opts = options ?? {}; + if (!Helpers.canBeginAction(actionStates, key)) + return ""; + if (!T3Connection.canOperate) + return rejectAction(key, "This pairing is read-only", opts.awaitResolution); + if (T3Connection.state !== "connected") + return rejectAction(key, "Not connected", opts.awaitResolution); + if (!Array.isArray(commands) || commands.length === 0) + return rejectAction(key, "Nothing to send", opts.awaitResolution); + + const firstId = commands[0].commandId ?? T3Rpc.genId(); + commands[0].commandId = firstId; + beginAction(key, firstId, opts.awaitResolution); + + function sendAt(index) { + if (!root.actionStates[key] || root.actionStates[key].pending !== true) + return; + if (index >= commands.length) { + if (opts.awaitResolution !== true && opts.holdAfterSuccess !== true) + root.clearAction(key); + opts.onSuccess?.(); + return; + } + const command = commands[index]; + if (!command.commandId) + command.commandId = T3Rpc.genId(); + const current = root.actionStates[key]; + root.putActionState(key, Object.assign({}, current, { + commandId: command.commandId, + startedAt: Date.now() + })); + T3Rpc.requestOnce("orchestration.dispatchCommand", command, () => { + sendAt(index + 1); + }, error => { + root.failAction(key, error || "Command rejected"); + opts.onFailure?.(error); + console.warn("t3code: command rejected:", error); + }, { actionKey: key, fallback: "Command rejected" }); + } + + sendAt(0); + return firstId; + } + + // Approval/input actions remain pending after RPC acceptance until the + // provider's matching resolution activity arrives. + function dispatch(command, key, awaitResolution) { + return dispatchBatch([command], key, { awaitResolution: awaitResolution === true }); + } + + // decision: "accept" | "acceptForSession" | "decline" + function respondApproval(threadId, requestId, decision) { + const key = actionKey("approval", threadId, requestId); + return dispatch({ + type: "thread.approval.respond", + commandId: T3Rpc.genId(), + threadId: threadId, + requestId: requestId, + decision: decision, + createdAt: new Date().toISOString() + }, key, true); + } + + // Answers are deliberately narrowed to the two provider contract shapes + // the dropdown can author: a string or an array of strings. + function respondUserInput(threadId, requestId, answers) { + const key = actionKey("input", threadId, requestId); + const normalized = {}; + let answerCount = 0; + if (!answers || typeof answers !== "object" || Array.isArray(answers)) { + putActionState(key, { pending: false, error: "Every question needs an answer", + commandId: "", awaitResolution: true, startedAt: Date.now() }); + return ""; + } + for (const questionId in answers) { + const value = answers[questionId]; + if (typeof value === "string") { + const answer = value.trim(); + if (answer === "") + continue; + normalized[questionId] = answer; + answerCount++; + } else if (Array.isArray(value)) { + const labels = value.filter(label => typeof label === "string") + .map(label => label.trim()).filter(label => label !== ""); + if (labels.length === 0) + continue; + normalized[questionId] = Array.from(new Set(labels)); + answerCount++; + } else { + putActionState(key, { pending: false, error: "Unsupported answer format", + commandId: "", awaitResolution: true, startedAt: Date.now() }); + return ""; + } + } + if (answerCount === 0) { + putActionState(key, { pending: false, error: "Every question needs an answer", + commandId: "", awaitResolution: true, startedAt: Date.now() }); + return ""; + } + return dispatch({ + type: "thread.user-input.respond", + commandId: T3Rpc.genId(), + threadId: threadId, + requestId: requestId, + answers: normalized, + createdAt: new Date().toISOString() + }, key, true); + } + + function settle(threadId) { + const key = actionKey("settle", threadId, ""); + const thread = T3Threads.threadMap[threadId]; + if (!supportsSettlement) + return rejectAction(key, "Settlement is not supported by this server", false); + if (!Helpers.canOperateLifecycle(thread, Date.now())) + return rejectAction(key, "Wait for the thread to become idle", false); + return dispatch({ + type: "thread.settle", + commandId: T3Rpc.genId(), + threadId: threadId + }, key, false); + } + + function unsettle(threadId) { + const key = actionKey("unsettle", threadId, ""); + if (!supportsSettlement) + return rejectAction(key, "Settlement is not supported by this server", false); + return dispatch({ + type: "thread.unsettle", + commandId: T3Rpc.genId(), + threadId: threadId, + reason: "user" + }, key, false); + } + + function settleMany(threadIds) { + const key = actionKey("bulk-settle", "", ""); + if (!supportsSettlement) + return rejectAction(key, "Settlement is not supported by this server", false); + const ids = Array.isArray(threadIds) ? threadIds.filter(id => + Helpers.canOperateLifecycle(T3Threads.threadMap[id], Date.now())) : []; + const commands = ids.map(id => ({ + type: "thread.settle", commandId: T3Rpc.genId(), threadId: id + })); + return dispatchBatch(commands, key, {}); + } + + function snooze(threadId, snoozedUntil) { + const key = actionKey("snooze", threadId, ""); + const thread = T3Threads.threadMap[threadId]; + if (!supportsSnooze) + return rejectAction(key, "Snooze is not supported by this server", false); + if (!Helpers.canOperateLifecycle(thread, Date.now())) + return rejectAction(key, "Wait for the thread to become idle", false); + if (isNaN(Date.parse(snoozedUntil)) || Date.parse(snoozedUntil) <= Date.now()) + return rejectAction(key, "Choose a future wake time", false); + return dispatch({ + type: "thread.snooze", + commandId: T3Rpc.genId(), + threadId: threadId, + snoozedUntil: snoozedUntil + }, key, false); + } + + function unsnooze(threadId) { + const key = actionKey("unsnooze", threadId, ""); + if (!supportsSnooze) + return rejectAction(key, "Snooze is not supported by this server", false); + return dispatch({ + type: "thread.unsnooze", + commandId: T3Rpc.genId(), + threadId: threadId, + reason: "user" + }, key, false); + } + + // Pinning is metadata, not lifecycle: the reference client offers it on + // running and blocked threads alike, so there is no idleness gate here. + // orderKey is omitted — the bar never reorders pins. + function pin(threadId) { + const key = actionKey("pin", threadId, ""); + if (!supportsPinning) + return rejectAction(key, "Pinning is not supported by this server", false); + return dispatch({ + type: "thread.pin", + commandId: T3Rpc.genId(), + threadId: threadId + }, key, false); + } + + function unpin(threadId) { + const key = actionKey("unpin", threadId, ""); + if (!supportsPinning) + return rejectAction(key, "Pinning is not supported by this server", false); + return dispatch({ + type: "thread.unpin", + commandId: T3Rpc.genId(), + threadId: threadId + }, key, false); + } + + function interrupt(threadId) { + return dispatch({ + type: "thread.turn.interrupt", + commandId: T3Rpc.genId(), + threadId: threadId, + createdAt: new Date().toISOString() + }, actionKey("interrupt", threadId, ""), false); + } + + function stopSession(threadId) { + return dispatch({ + type: "thread.session.stop", + commandId: T3Rpc.genId(), + threadId: threadId, + createdAt: new Date().toISOString() + }, actionKey("session-stop", threadId, ""), false); + } + + function renameThread(threadId, title) { + const key = actionKey("rename", threadId, ""); + const normalized = typeof title === "string" ? title.trim() : ""; + if (normalized === "") + return rejectAction(key, "Title cannot be empty", false); + return dispatch({ + type: "thread.meta.update", + commandId: T3Rpc.genId(), + threadId: threadId, + title: normalized + }, key, false); + } + + function regenerateTitle(threadId) { + const key = actionKey("regenerate-title", threadId, ""); + if (!supportsTitleRegeneration) + return rejectAction(key, "Title regeneration is not supported", false); + if (T3Threads.threadMap[threadId]?.titleRegeneration) + return rejectAction(key, "Title regeneration is already running", false); + return dispatch({ + type: "thread.meta.update", + commandId: T3Rpc.genId(), + threadId: threadId, + regenerateTitle: true + }, key, false); + } + + + function expire(now) { + const result = Helpers.expireActionStates(actionStates, now, actionTimeoutMs); + if (result.expiredKeys.length > 0) + actionStates = result.states; + } + + Timer { + interval: 500 + repeat: true + running: T3Connection.state === "connected" + && Object.values(root.actionStates).some(state => state && state.pending === true) + onTriggered: root.expire(Date.now()) + } +} diff --git a/roles/desktop/files/quickshell/Common/T3Rpc.qml b/roles/desktop/files/quickshell/Common/T3Rpc.qml index 758d3e4f..0d6d73dc 100644 --- a/roles/desktop/files/quickshell/Common/T3Rpc.qml +++ b/roles/desktop/files/quickshell/Common/T3Rpc.qml @@ -3,8 +3,8 @@ import QtQuick import Quickshell import "T3CodeHelpers.js" as Helpers -// Request/response over the T3 socket, and the state machine that tracks a -// dispatched command until the server confirms it. +// Request/response correlation over the T3 socket. Domain actions live in +// T3Actions; this facade retains the existing public command surface. // // This is the layer between the transport (Common/T3Connection.qml, which // knows only frames) and the domain (Common/T3Code.qml, which knows threads). @@ -17,25 +17,12 @@ import "T3CodeHelpers.js" as Helpers Singleton { id: root - // Per-command UI state, keyed by actionKey(kind, threadId, requestId): - // { pending, error, commandId, ... }. A finished entry is left in place so - // the button can keep showing why it failed, which is why the deadline - // sweep below only ever looks at pending ones. - property var actionStates: ({}) - readonly property int actionTimeoutMs: 15000 - - // Optional-capability gates for the lifecycle commands below. Derived from - // the connection here — where the commands live — and re-exported by - // T3Code; a missing key means an older server, so the command is never - // sent under version skew. - readonly property bool supportsSettlement: - T3Connection.environmentCapabilities.threadSettlement === true - readonly property bool supportsSnooze: - T3Connection.environmentCapabilities.threadSnooze === true - readonly property bool supportsTitleRegeneration: - T3Connection.environmentCapabilities.threadTitleRegeneration === true - readonly property bool supportsPinning: - T3Connection.environmentCapabilities.threadPinning === true + readonly property var actionStates: T3Actions.actionStates + readonly property int actionTimeoutMs: T3Actions.actionTimeoutMs + readonly property bool supportsSettlement: T3Actions.supportsSettlement + readonly property bool supportsSnooze: T3Actions.supportsSnooze + readonly property bool supportsTitleRegeneration: T3Actions.supportsTitleRegeneration + readonly property bool supportsPinning: T3Actions.supportsPinning function putRpcHandler(id, handler) { dropRpcHandler(id); @@ -140,17 +127,13 @@ Singleton { } - // Deadline sweep for in-flight RPCs and pending actions. Both are empty - // most of the time, so the tick is gated on there being something to - // expire: rpcDeadlineCount notifies where rpcHandlers cannot, and - // expireActionStates only ever touches pending entries — a finished - // action left in place to show its error must not keep this running. + // Only wire requests with deadlines keep this sweep awake. Action + // feedback has its own lifecycle in T3Actions, independent of streams. Timer { interval: 500 repeat: true running: T3Connection.state === "connected" - && (root.rpcDeadlineCount > 0 - || Object.values(root.actionStates).some(state => state && state.pending === true)) + && root.rpcDeadlineCount > 0 onTriggered: { const now = Date.now(); for (const id in root.rpcHandlers) { @@ -161,365 +144,127 @@ Singleton { root.dropRpcHandler(id); handler.timeout?.(); } - const expired = Helpers.expireActionStates(root.actionStates, now, - root.actionTimeoutMs); - if (expired.expiredKeys.length > 0) - root.actionStates = expired.states; } } - // ---- commands and action state --------------------------------------- + // Compatibility facade for domain commands and per-action feedback. function actionKey(kind, threadId, requestId) { - return kind + "|" + threadId + "|" + (requestId ?? ""); + return T3Actions.actionKey(kind, threadId, requestId); } function actionState(kind, threadId, requestId) { - const states = actionStates; - return states[actionKey(kind, threadId, requestId)] ?? null; + return T3Actions.actionState(kind, threadId, requestId); } function actionPending(kind, threadId, requestId) { - const current = actionState(kind, threadId, requestId); - return current !== null && current.pending === true; + return T3Actions.actionPending(kind, threadId, requestId); } function actionError(kind, threadId, requestId) { - const current = actionState(kind, threadId, requestId); - return current && typeof current.error === "string" ? current.error : ""; + return T3Actions.actionError(kind, threadId, requestId); } function putActionState(key, value) { - const next = Object.assign({}, actionStates); - if (value === null) - delete next[key]; - else - next[key] = value; - actionStates = next; + return T3Actions.putActionState(key, value); } function beginAction(key, commandId, awaitResolution, timeoutMs) { - const state = { - pending: true, - error: "", - commandId: commandId, - awaitResolution: awaitResolution === true, - startedAt: Date.now() - }; - if (typeof timeoutMs === "number" && timeoutMs > 0) - state.timeoutMs = timeoutMs; - putActionState(key, state); + return T3Actions.beginAction(key, commandId, awaitResolution, timeoutMs); } function failAllPendingActions(message) { - const next = Object.assign({}, actionStates); - let changed = false; - for (const key in next) { - if (!next[key] || next[key].pending !== true) - continue; - next[key] = Object.assign({}, next[key], { - pending: false, - error: message || "Disconnected before confirmation" - }); - changed = true; - } - if (changed) - actionStates = next; + return T3Actions.failAllPendingActions(message); } function failAction(key, message) { - const current = actionStates[key]; - if (!current) - return; - putActionState(key, Object.assign({}, current, { - pending: false, - error: message || "Action failed" - })); + return T3Actions.failAction(key, message); } function clearAction(key) { - if (actionStates[key] !== undefined) - putActionState(key, null); - } - - function cancelActionRequests(key) { - for (const id in rpcHandlers) { - const handler = rpcHandlers[id]; - if (!handler || handler.actionKey !== key) - continue; - T3Connection.send(JSON.stringify({ _tag: "Interrupt", requestId: id })); - dropRpcHandler(id); - } - clearAction(key); + return T3Actions.clearAction(key); } - function failureMessage(msg, fallback) { - const found = Helpers.findErrorText(msg ? msg.exit : null, 0); - return found !== "" ? found.slice(0, 240) : fallback; + function rejectAction(key, message, awaitResolution) { + return T3Actions.rejectAction(key, message, awaitResolution); } - function rejectAction(key, message, awaitResolution) { - putActionState(key, { - pending: false, - error: message, - commandId: "", - awaitResolution: awaitResolution === true, - startedAt: Date.now() - }); - return ""; - } - - // Dispatch commands one at a time. A later command is never attempted - // after an earlier rejection, and reconnecting never replays the batch. function dispatchBatch(commands, key, options) { - const opts = options ?? {}; - if (!Helpers.canBeginAction(actionStates, key)) - return ""; - if (!T3Connection.canOperate) - return rejectAction(key, "This pairing is read-only", opts.awaitResolution); - if (T3Connection.state !== "connected") - return rejectAction(key, "Not connected", opts.awaitResolution); - if (!Array.isArray(commands) || commands.length === 0) - return rejectAction(key, "Nothing to send", opts.awaitResolution); - - const firstId = commands[0].commandId ?? genId(); - commands[0].commandId = firstId; - beginAction(key, firstId, opts.awaitResolution); - - function sendAt(index) { - if (!root.actionStates[key] || root.actionStates[key].pending !== true) - return; - if (index >= commands.length) { - if (opts.awaitResolution !== true && opts.holdAfterSuccess !== true) - root.clearAction(key); - opts.onSuccess?.(); - return; - } - const command = commands[index]; - if (!command.commandId) - command.commandId = root.genId(); - const current = root.actionStates[key]; - root.putActionState(key, Object.assign({}, current, { - commandId: command.commandId, - startedAt: Date.now() - })); - requestOnce("orchestration.dispatchCommand", command, () => { - sendAt(index + 1); - }, error => { - root.failAction(key, error || "Command rejected"); - opts.onFailure?.(error); - console.warn("t3code: command rejected:", error); - }, { actionKey: key, fallback: "Command rejected" }); - } - - sendAt(0); - return firstId; + return T3Actions.dispatchBatch(commands, key, options); } - // Approval/input actions remain pending after RPC acceptance until the - // provider's matching resolution activity arrives. function dispatch(command, key, awaitResolution) { - return dispatchBatch([command], key, { awaitResolution: awaitResolution === true }); + return T3Actions.dispatch(command, key, awaitResolution); } - // decision: "accept" | "acceptForSession" | "decline" function respondApproval(threadId, requestId, decision) { - const key = actionKey("approval", threadId, requestId); - return dispatch({ - type: "thread.approval.respond", - commandId: genId(), - threadId: threadId, - requestId: requestId, - decision: decision, - createdAt: new Date().toISOString() - }, key, true); - } - - // Answers are deliberately narrowed to the two provider contract shapes - // the dropdown can author: a string or an array of strings. + return T3Actions.respondApproval(threadId, requestId, decision); + } + function respondUserInput(threadId, requestId, answers) { - const key = actionKey("input", threadId, requestId); - const normalized = {}; - let answerCount = 0; - if (!answers || typeof answers !== "object" || Array.isArray(answers)) { - putActionState(key, { pending: false, error: "Every question needs an answer", - commandId: "", awaitResolution: true, startedAt: Date.now() }); - return ""; - } - for (const questionId in answers) { - const value = answers[questionId]; - if (typeof value === "string") { - const answer = value.trim(); - if (answer === "") - continue; - normalized[questionId] = answer; - answerCount++; - } else if (Array.isArray(value)) { - const labels = value.filter(label => typeof label === "string") - .map(label => label.trim()).filter(label => label !== ""); - if (labels.length === 0) - continue; - normalized[questionId] = Array.from(new Set(labels)); - answerCount++; - } else { - putActionState(key, { pending: false, error: "Unsupported answer format", - commandId: "", awaitResolution: true, startedAt: Date.now() }); - return ""; - } - } - if (answerCount === 0) { - putActionState(key, { pending: false, error: "Every question needs an answer", - commandId: "", awaitResolution: true, startedAt: Date.now() }); - return ""; - } - return dispatch({ - type: "thread.user-input.respond", - commandId: genId(), - threadId: threadId, - requestId: requestId, - answers: normalized, - createdAt: new Date().toISOString() - }, key, true); + return T3Actions.respondUserInput(threadId, requestId, answers); } function settle(threadId) { - const key = actionKey("settle", threadId, ""); - const thread = T3Threads.threadMap[threadId]; - if (!supportsSettlement) - return rejectAction(key, "Settlement is not supported by this server", false); - if (!Helpers.canOperateLifecycle(thread, Date.now())) - return rejectAction(key, "Wait for the thread to become idle", false); - return dispatch({ - type: "thread.settle", - commandId: genId(), - threadId: threadId - }, key, false); + return T3Actions.settle(threadId); } function unsettle(threadId) { - const key = actionKey("unsettle", threadId, ""); - if (!supportsSettlement) - return rejectAction(key, "Settlement is not supported by this server", false); - return dispatch({ - type: "thread.unsettle", - commandId: genId(), - threadId: threadId, - reason: "user" - }, key, false); + return T3Actions.unsettle(threadId); } function settleMany(threadIds) { - const key = actionKey("bulk-settle", "", ""); - if (!supportsSettlement) - return rejectAction(key, "Settlement is not supported by this server", false); - const ids = Array.isArray(threadIds) ? threadIds.filter(id => - Helpers.canOperateLifecycle(T3Threads.threadMap[id], Date.now())) : []; - const commands = ids.map(id => ({ - type: "thread.settle", commandId: genId(), threadId: id - })); - return dispatchBatch(commands, key, {}); + return T3Actions.settleMany(threadIds); } function snooze(threadId, snoozedUntil) { - const key = actionKey("snooze", threadId, ""); - const thread = T3Threads.threadMap[threadId]; - if (!supportsSnooze) - return rejectAction(key, "Snooze is not supported by this server", false); - if (!Helpers.canOperateLifecycle(thread, Date.now())) - return rejectAction(key, "Wait for the thread to become idle", false); - if (isNaN(Date.parse(snoozedUntil)) || Date.parse(snoozedUntil) <= Date.now()) - return rejectAction(key, "Choose a future wake time", false); - return dispatch({ - type: "thread.snooze", - commandId: genId(), - threadId: threadId, - snoozedUntil: snoozedUntil - }, key, false); + return T3Actions.snooze(threadId, snoozedUntil); } function unsnooze(threadId) { - const key = actionKey("unsnooze", threadId, ""); - if (!supportsSnooze) - return rejectAction(key, "Snooze is not supported by this server", false); - return dispatch({ - type: "thread.unsnooze", - commandId: genId(), - threadId: threadId, - reason: "user" - }, key, false); - } - - // Pinning is metadata, not lifecycle: the reference client offers it on - // running and blocked threads alike, so there is no idleness gate here. - // orderKey is omitted — the bar never reorders pins. + return T3Actions.unsnooze(threadId); + } + function pin(threadId) { - const key = actionKey("pin", threadId, ""); - if (!supportsPinning) - return rejectAction(key, "Pinning is not supported by this server", false); - return dispatch({ - type: "thread.pin", - commandId: genId(), - threadId: threadId - }, key, false); + return T3Actions.pin(threadId); } function unpin(threadId) { - const key = actionKey("unpin", threadId, ""); - if (!supportsPinning) - return rejectAction(key, "Pinning is not supported by this server", false); - return dispatch({ - type: "thread.unpin", - commandId: genId(), - threadId: threadId - }, key, false); + return T3Actions.unpin(threadId); } function interrupt(threadId) { - return dispatch({ - type: "thread.turn.interrupt", - commandId: genId(), - threadId: threadId, - createdAt: new Date().toISOString() - }, actionKey("interrupt", threadId, ""), false); + return T3Actions.interrupt(threadId); } function stopSession(threadId) { - return dispatch({ - type: "thread.session.stop", - commandId: genId(), - threadId: threadId, - createdAt: new Date().toISOString() - }, actionKey("session-stop", threadId, ""), false); + return T3Actions.stopSession(threadId); } function renameThread(threadId, title) { - const key = actionKey("rename", threadId, ""); - const normalized = typeof title === "string" ? title.trim() : ""; - if (normalized === "") - return rejectAction(key, "Title cannot be empty", false); - return dispatch({ - type: "thread.meta.update", - commandId: genId(), - threadId: threadId, - title: normalized - }, key, false); + return T3Actions.renameThread(threadId, title); } function regenerateTitle(threadId) { - const key = actionKey("regenerate-title", threadId, ""); - if (!supportsTitleRegeneration) - return rejectAction(key, "Title regeneration is not supported", false); - if (T3Threads.threadMap[threadId]?.titleRegeneration) - return rejectAction(key, "Title regeneration is already running", false); - return dispatch({ - type: "thread.meta.update", - commandId: genId(), - threadId: threadId, - regenerateTitle: true - }, key, false); + return T3Actions.regenerateTitle(threadId); } + function cancelActionRequests(key) { + for (const id in rpcHandlers) { + const handler = rpcHandlers[id]; + if (!handler || handler.actionKey !== key) + continue; + T3Connection.send(JSON.stringify({ _tag: "Interrupt", requestId: id })); + dropRpcHandler(id); + } + clearAction(key); + } + + function failureMessage(msg, fallback) { + const found = Helpers.findErrorText(msg ? msg.exit : null, 0); + return found !== "" ? found.slice(0, 240) : fallback; + } readonly property string shellReqId: "1" property int nextReqId: 2 diff --git a/roles/desktop/files/quickshell/Common/UpdateLogReader.qml b/roles/desktop/files/quickshell/Common/UpdateLogReader.qml new file mode 100644 index 00000000..9d690a69 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/UpdateLogReader.qml @@ -0,0 +1,28 @@ +import QtQuick +import "UpdatesHelpers.js" as UpdatesHelpers + +// A byte-range read is bound to the run and offset that issued it. The +// transaction model owns offsets/parsing; this transport rejects stale reads +// and publishes only complete successful responses. +CommandRequest { + id: root + required property string kind + property string currentRun: "" + property int currentOffset: 0 + property string targetRunStamp: "" + property int sourceOffset: 0 + property int targetOffset: 0 + signal accepted(string body, int offset) + signal stale() + timeoutMessage: kind + " update log read timed out" + onCompleted: (code, body, error) => { + if (UpdatesHelpers.acceptsLogRead(currentRun, currentOffset, + targetRunStamp, sourceOffset, targetOffset, true, code)) { + accepted(body, targetOffset); + } else if (targetRunStamp !== currentRun || sourceOffset !== currentOffset) { + stale(); + } else if (code !== 0) { + console.warn(kind + " update log read failed:", code, error); + } + } +} diff --git a/roles/desktop/files/quickshell/Common/Updates.qml b/roles/desktop/files/quickshell/Common/Updates.qml index 04e6bf8d..5fb25aa8 100644 --- a/roles/desktop/files/quickshell/Common/Updates.qml +++ b/roles/desktop/files/quickshell/Common/Updates.qml @@ -1274,105 +1274,31 @@ Singleton { } } - Process { + UpdateLogReader { id: dnfLogReadProc - property string targetRunStamp: "" - property int sourceOffset: 0 - property int targetOffset: 0 - property string body: "" - property bool exitSeen: false - property int lastExit: -1 - stdout: StdioCollector { - onStreamFinished: dnfLogReadProc.body = text - } - onExited: (exitCode, exitStatus) => { - dnfLogReadProc.exitSeen = true; - dnfLogReadProc.lastExit = exitCode; - } - onRunningChanged: { - if (running) { - body = ""; - exitSeen = false; - lastExit = -1; - } else if (UpdatesHelpers.acceptsLogRead(root.runStamp, - root.dnfLogOffset, targetRunStamp, sourceOffset, - targetOffset, exitSeen, lastExit)) { - root.consumeBackendLog("dnf", body, targetOffset); - } else if (exitSeen && (targetRunStamp !== root.runStamp - || sourceOffset !== root.dnfLogOffset)) { - // The process slot is free again; immediately service the - // current run rather than waiting for its next status poll. - root.drainBackendLogs(); - } else if (exitSeen && lastExit !== 0) { - console.warn("dnf update log read exited with status", lastExit); - } - } + kind: "dnf" + currentRun: root.runStamp + currentOffset: root.dnfLogOffset + onAccepted: (body, offset) => root.consumeBackendLog("dnf", body, offset) + onStale: root.drainBackendLogs() } - Process { + UpdateLogReader { id: flatpakLogReadProc - property string targetRunStamp: "" - property int sourceOffset: 0 - property int targetOffset: 0 - property string body: "" - property bool exitSeen: false - property int lastExit: -1 - stdout: StdioCollector { - onStreamFinished: flatpakLogReadProc.body = text - } - onExited: (exitCode, exitStatus) => { - flatpakLogReadProc.exitSeen = true; - flatpakLogReadProc.lastExit = exitCode; - } - onRunningChanged: { - if (running) { - body = ""; - exitSeen = false; - lastExit = -1; - } else if (UpdatesHelpers.acceptsLogRead(root.runStamp, - root.flatpakLogOffset, targetRunStamp, sourceOffset, - targetOffset, exitSeen, lastExit)) { - root.consumeBackendLog("flatpak", body, targetOffset); - } else if (exitSeen && (targetRunStamp !== root.runStamp - || sourceOffset !== root.flatpakLogOffset)) { - root.drainBackendLogs(); - } else if (exitSeen && lastExit !== 0) { - console.warn("flatpak update log read exited with status", lastExit); - } - } + kind: "flatpak" + currentRun: root.runStamp + currentOffset: root.flatpakLogOffset + onAccepted: (body, offset) => root.consumeBackendLog("flatpak", body, offset) + onStale: root.drainBackendLogs() } - Process { + UpdateLogReader { id: firmwareLogReadProc - property string targetRunStamp: "" - property int sourceOffset: 0 - property int targetOffset: 0 - property string body: "" - property bool exitSeen: false - property int lastExit: -1 - stdout: StdioCollector { - onStreamFinished: firmwareLogReadProc.body = text - } - onExited: (exitCode, exitStatus) => { - firmwareLogReadProc.exitSeen = true; - firmwareLogReadProc.lastExit = exitCode; - } - onRunningChanged: { - if (running) { - body = ""; - exitSeen = false; - lastExit = -1; - } else if (UpdatesHelpers.acceptsLogRead(root.runStamp, - root.firmwareLogOffset, targetRunStamp, sourceOffset, - targetOffset, exitSeen, lastExit)) { - root.consumeBackendLog("firmware", body, targetOffset); - } else if (exitSeen && (targetRunStamp !== root.runStamp - || sourceOffset !== root.firmwareLogOffset)) { - root.drainBackendLogs(); - } else if (exitSeen && lastExit !== 0) { - console.warn("firmware update log read exited with status", lastExit); - } - } + kind: "firmware" + currentRun: root.runStamp + currentOffset: root.firmwareLogOffset + onAccepted: (body, offset) => root.consumeBackendLog("firmware", body, offset) + onStale: root.drainBackendLogs() } // The falling edge, so a cancel client that never started still asks diff --git a/roles/desktop/files/quickshell/Common/qmldir b/roles/desktop/files/quickshell/Common/qmldir index 58a0aa22..f20d482c 100644 --- a/roles/desktop/files/quickshell/Common/qmldir +++ b/roles/desktop/files/quickshell/Common/qmldir @@ -21,6 +21,7 @@ singleton LauncherProviders LauncherProviders.qml singleton T3Code T3Code.qml singleton T3Connection T3Connection.qml singleton T3Rpc T3Rpc.qml +singleton T3Actions T3Actions.qml singleton T3Detail T3Detail.qml singleton T3Drafts T3Drafts.qml singleton T3Favorites T3Favorites.qml @@ -97,3 +98,6 @@ singleton SystemSettings SystemSettings.qml singleton DisplaySettings DisplaySettings.qml SystemSettingsBackend SystemSettingsBackend.qml singleton RemoteServer RemoteServer.qml + +CommandRequest CommandRequest.qml +UpdateLogReader UpdateLogReader.qml diff --git a/roles/desktop/files/quickshell/Settings/InputDraft.js b/roles/desktop/files/quickshell/Settings/InputDraft.js new file mode 100644 index 00000000..ee10389f --- /dev/null +++ b/roles/desktop/files/quickshell/Settings/InputDraft.js @@ -0,0 +1,32 @@ +// Kept free of Qt APIs so the page's actual draft behavior runs under QtTest. +function clone(value) { return JSON.parse(JSON.stringify(value)); } +function patch(current, original, keys) { + var result = {}; + keys.forEach(function(key) { + if (JSON.stringify(current[key]) !== JSON.stringify(original[key])) result[key] = clone(current[key]); + }); + return result; +} +function changeLayout(layouts, index, layout, variant) { + var result = clone(layouts); + result[index] = {layout: layout, variant: variant || ""}; + return result; +} +function move(layouts, index, delta) { + var result = clone(layouts); + var other = index + delta; + if (index >= 0 && index < result.length && other >= 0 && other < result.length) { + var value = result[index]; result[index] = result[other]; result[other] = value; + } + return result; +} +function filtered(choices, query, selected) { + var text = query.trim().toLowerCase(); + var result = choices.filter(function(choice) { + return choice.value === selected || (choice.label + " " + choice.value).toLowerCase().indexOf(text) !== -1; + }); + if (selected && !choices.some(function(choice) { return choice.value === selected; })) + result.unshift({value: selected, label: selected + " (current)"}); + return result; +} +if (typeof module !== "undefined") module.exports = {clone: clone, patch: patch, changeLayout: changeLayout, move: move, filtered: filtered}; diff --git a/roles/desktop/files/quickshell/Settings/KeyboardPage.qml b/roles/desktop/files/quickshell/Settings/KeyboardPage.qml new file mode 100644 index 00000000..64f477da --- /dev/null +++ b/roles/desktop/files/quickshell/Settings/KeyboardPage.qml @@ -0,0 +1,145 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "../Common" +import "InputDraft.js" as Draft + +SettingsPage { + id: page + readonly property SystemSettingsBackend service: SystemSettings.input + property var draft: ({layouts: [], shortcut: ""}) + property var original: ({layouts: [], shortcut: ""}) + property string version: "" + property string query: "" + readonly property var changes: Draft.patch(draft, original, ["layouts", "shortcut"]) + readonly property bool dirty: Object.keys(changes).length > 0 + readonly property var catalog: service.snapshot.catalog || [] + readonly property string disabledReason: service.busy ? "Applying…" : !service.loaded ? "Loading…" : "" + bottomInset: applyBar.reservedHeight + + function load() { + if (!service.loaded || !service.snapshot.keyboard) return; + original = Draft.clone(service.snapshot.keyboard); + draft = Draft.clone(original); + version = service.snapshot.version; + } + function editLayouts(value) { draft = Object.assign({}, draft, {layouts: value}); } + function variants(layout) { + const entry = catalog.find(choice => choice.value === layout); + return entry ? entry.variants : [{value: "", label: "Default"}]; + } + Claim { + active: page.visible && Settings.panelOpen + onClaimed: { page.service.acquire(); page.load(); } + onReleased: page.service.release() + } + Connections { + target: page.service + function onSnapshotChanged() { if (!page.dirty) page.load(); } + function onLoadedChanged() { if (!page.dirty) page.load(); } + function onCompleted(result) { if (result.success) page.original = Draft.clone(page.draft); } + } + overlay: ApplyBar { + id: applyBar + pending: page.dirty + title: "Keyboard changes not applied yet" + detail: "Layouts apply to your desktop session." + busy: page.service.busy + applyEnabled: page.service.loaded && page.draft.layouts.length > 0 + onDiscard: page.load() + onApply: page.service.run({action: "apply", section: "keyboard", version: page.version, values: page.changes}) + } + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Theme.settingsGroupSpacing + SystemServiceStatus { width: parent.width; service: page.service; notice: page.service.message } + SettingsGroup { + width: parent.width + title: "Keyboard layouts" + DraftFieldRow { + width: parent.width + label: "Find a layout" + value: page.query + mono: false + placeholder: "Language or country" + onEdited: text => page.query = text + } + Repeater { + model: page.draft.layouts.length + delegate: RowCluster { + id: entry + required property int index + readonly property var layout: page.draft.layouts[index] + width: parent.width + SelectRow { + width: parent.width + label: "Layout " + (entry.index + 1) + model: Draft.filtered(page.catalog, page.query, entry.layout.layout) + current: entry.layout.layout + disabledReason: page.disabledReason + onPicked: value => page.editLayouts(Draft.changeLayout(page.draft.layouts, entry.index, value, "")) + } + SelectRow { + width: parent.width + label: "Variant" + model: page.variants(entry.layout.layout) + current: entry.layout.variant || "" + disabledReason: page.disabledReason + onPicked: value => page.editLayouts(Draft.changeLayout(page.draft.layouts, entry.index, entry.layout.layout, value)) + } + ValueRow { + width: parent.width + label: entry.index === 0 ? "Default layout" : "Layout order" + SettingsAction { + text: "Move up" + visible: entry.index > 0 + enabled: !page.service.busy + onTriggered: page.editLayouts(Draft.move(page.draft.layouts, entry.index, -1)) + } + SettingsAction { + text: "Remove" + enabled: page.draft.layouts.length > 1 && !page.service.busy + onTriggered: page.editLayouts(page.draft.layouts.filter((_, at) => at !== entry.index)) + } + } + } + } + ValueRow { + width: parent.width + label: "Add another layout" + hint: "Up to four layouts; the first is used when you sign in." + SettingsAction { + text: "Add layout" + glyph: "add" + enabled: page.service.loaded && !page.service.busy && page.draft.layouts.length < 4 && page.catalog.length > 0 + onTriggered: page.editLayouts(page.draft.layouts.concat([{layout: page.catalog.some(c => c.value === "us") ? "us" : page.catalog[0].value, variant: ""}])) + } + } + } + SettingsGroup { + width: parent.width + title: "Switch layouts" + SelectRow { + width: parent.width + label: "Shortcut" + current: page.draft.shortcut + model: Draft.filtered([{value: "", label: "None"}, + {value: "grp:alt_shift_toggle", label: "Alt + Shift"}, {value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift"}, + {value: "grp:caps_toggle", label: "Caps Lock"}], "", page.draft.shortcut) + disabledReason: page.disabledReason + onPicked: value => page.draft = Object.assign({}, page.draft, {shortcut: value}) + } + ValueRow { + width: parent.width + label: "Current layout" + value: (page.service.snapshot.keyboard?.active || []).map(device => device.layout).join(", ") + SettingsAction { + text: "Next layout" + enabled: page.service.loaded && !page.service.busy && !page.dirty + onTriggered: page.service.run({action: "switch"}) + } + } + } + } +} diff --git a/roles/desktop/files/quickshell/Settings/RegionPage.qml b/roles/desktop/files/quickshell/Settings/RegionPage.qml index 73461f6f..902834d1 100644 --- a/roles/desktop/files/quickshell/Settings/RegionPage.qml +++ b/roles/desktop/files/quickshell/Settings/RegionPage.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import "../Common" +import "InputDraft.js" as Draft // Region & formats: how the shell writes the time and the temperature. Each // row's caption reads the result back just before its choices ("Now 19:16", @@ -10,6 +11,40 @@ import "../Common" SettingsPage { id: page pageReset: true + readonly property SystemSettingsBackend service: SystemSettings.region + property string timezone: "" + property string locale: "" + property string timezoneQuery: "" + property string localeQuery: "" + property string originalTimezone: "" + property string originalLocale: "" + property var originalLocaleValues: [] + readonly property bool timezoneDirty: timezone !== originalTimezone + readonly property bool localeDirty: locale !== originalLocale + readonly property string disabledReason: service.busy ? "Applying…" : !service.loaded ? "Loading…" : "" + function load() { + if (!service.loaded) return; + if (!timezoneDirty) timezone = originalTimezone = service.snapshot.timezone || ""; + if (!localeDirty) { + locale = originalLocale = service.snapshot.locale || ""; + originalLocaleValues = service.snapshot.localeValues || []; + } + } + Claim { + active: page.visible && Settings.panelOpen + onClaimed: { page.service.acquire(); page.load(); } + onReleased: page.service.release() + } + Connections { + target: page.service + function onSnapshotChanged() { page.load(); } + function onLoadedChanged() { page.load(); } + function onCompleted(result) { + if (!result.success) return; + if (page.service.request.action === "timezone") page.originalTimezone = page.timezone; + if (page.service.request.action === "locale") page.originalLocale = page.locale; + } + } // The clock caption shows hours and minutes, so tick on the minute, and // only while the page is on screen. @@ -25,6 +60,82 @@ SettingsPage { anchors.top: parent.top spacing: Theme.settingsGroupSpacing + SystemServiceStatus { width: parent.width; service: page.service; notice: page.service.message } + SettingsGroup { + width: parent.width + title: "System timezone" + DraftFieldRow { + width: parent.width + label: "Find a timezone" + value: page.timezoneQuery + placeholder: "City or region" + mono: false + onEdited: text => page.timezoneQuery = text + } + SelectRow { + width: parent.width + label: "Timezone" + current: page.timezone + model: Draft.filtered((page.service.snapshot.timezones || []).map(value => ({value: value, label: value.replace(/_/g, " ")})), page.timezoneQuery, page.timezone) + disabledReason: page.disabledReason + hint: "Changes the timezone for everyone on this computer. Authorization may be required." + onPicked: value => page.timezone = value + } + ValueRow { + width: parent.width + visible: page.timezoneDirty + label: "Timezone change" + SettingsAction { + text: "Discard" + enabled: !page.service.busy + onTriggered: { page.timezone = page.originalTimezone; page.load(); } + } + SettingsAction { + text: "Apply timezone" + primary: true + enabled: page.service.loaded && !page.service.busy + onTriggered: page.service.run({action: "timezone", value: page.timezone, previous: page.originalTimezone}) + } + } + } + SettingsGroup { + width: parent.width + title: "System language" + DraftFieldRow { + width: parent.width + label: "Find a locale" + value: page.localeQuery + placeholder: "For example en_US or nl_NL" + onEdited: text => page.localeQuery = text + } + SelectRow { + width: parent.width + label: "Language and region" + current: page.locale + model: Draft.filtered((page.service.snapshot.locales || []).map(value => ({value: value, label: value})), page.localeQuery, page.locale) + disabledReason: page.disabledReason + hint: "Installed locales only. Applies system wide after signing out; explicit regional format overrides are preserved." + onPicked: value => page.locale = value + } + ValueRow { + width: parent.width + visible: page.localeDirty + label: "Language change" + hint: "Authorization may be required." + SettingsAction { + text: "Discard" + enabled: !page.service.busy + onTriggered: { page.locale = page.originalLocale; page.load(); } + } + SettingsAction { + text: "Apply language" + primary: true + enabled: page.service.loaded && !page.service.busy + onTriggered: page.service.run({action: "locale", value: page.locale, previous: page.originalLocaleValues}) + } + } + } + SettingsGroup { width: parent.width title: "Formats" diff --git a/roles/desktop/files/quickshell/Settings/SettingsView.qml b/roles/desktop/files/quickshell/Settings/SettingsView.qml index 86eb650d..df3ddd28 100644 --- a/roles/desktop/files/quickshell/Settings/SettingsView.qml +++ b/roles/desktop/files/quickshell/Settings/SettingsView.qml @@ -612,6 +612,7 @@ PopoutPanel { case "sound": return soundPage; case "network": return networkPage; case "touchpad": return touchpadPage; + case "keyboard": return keyboardPage; case "power": return powerPage; case "region": return regionPage; case "accounts": return accountsPage; @@ -801,6 +802,7 @@ PopoutPanel { Component { id: displaysPage; DisplaysPage {} } Component { id: accountsPage; AccountsPage {} } Component { id: touchpadPage; TouchpadPage {} } + Component { id: keyboardPage; KeyboardPage {} } Component { id: powerPage; PowerPage {} } Component { id: regionPage; RegionPage {} } Component { id: aboutPage; AboutPage {} } diff --git a/roles/desktop/files/quickshell/Settings/TouchpadPage.qml b/roles/desktop/files/quickshell/Settings/TouchpadPage.qml index 7ba72df5..708f40d4 100644 --- a/roles/desktop/files/quickshell/Settings/TouchpadPage.qml +++ b/roles/desktop/files/quickshell/Settings/TouchpadPage.qml @@ -1,22 +1,91 @@ import QtQuick import "../Common" +import "InputDraft.js" as Draft // Touchpad: how far a two-finger scroll moves. Pointer and keyboard settings // would join it here. SettingsPage { id: page pageReset: true + readonly property SystemSettingsBackend service: SystemSettings.input + property var draft: ({tap: true, naturalScroll: true, sensitivity: 0}) + property var original: ({tap: true, naturalScroll: true, sensitivity: 0}) + property string version: "" + readonly property var changes: Draft.patch(draft, original, ["tap", "naturalScroll", "sensitivity"]) + readonly property bool dirty: Object.keys(changes).length > 0 + readonly property string disabledReason: service.busy ? "Applying…" : !service.loaded ? "Loading…" : "" + bottomInset: applyBar.reservedHeight + function load() { + if (!service.loaded || !service.snapshot.touchpad) return; + original = Draft.clone(service.snapshot.touchpad); + draft = Draft.clone(original); + version = service.snapshot.version; + } + function edit(key, value) { + const updated = Draft.clone(draft); + updated[key] = value; + draft = updated; + } + Claim { + active: page.visible && Settings.panelOpen + onClaimed: { page.service.acquire(); page.load(); } + onReleased: page.service.release() + } + Connections { + target: page.service + function onSnapshotChanged() { if (!page.dirty) page.load(); } + function onLoadedChanged() { if (!page.dirty) page.load(); } + function onCompleted(result) { if (result.success) page.original = Draft.clone(page.draft); } + } + overlay: ApplyBar { + id: applyBar + pending: page.dirty + title: "Touchpad changes not applied yet" + busy: page.service.busy + onDiscard: page.load() + onApply: page.service.run({action: "apply", section: "touchpad", version: page.version, values: page.changes}) + } Column { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top spacing: Theme.settingsGroupSpacing + SystemServiceStatus { width: parent.width; service: page.service; notice: page.service.message } SettingsGroup { width: parent.width title: "Touchpad" + SwitchRow { + width: parent.width + label: "Tap to click" + checked: page.draft.tap + disabledReason: page.disabledReason + onToggled: value => page.edit("tap", value) + } + SwitchRow { + width: parent.width + label: "Natural scrolling" + description: "Move content in the direction your fingers move." + checked: page.draft.naturalScroll + disabledReason: page.disabledReason + onToggled: value => page.edit("naturalScroll", value) + } + SliderRow { + width: parent.width + label: "Pointer sensitivity" + hint: "Affects touchpads and mice. Per-device Hyprland rules take precedence." + min: -1 + max: 1 + step: 0.05 + decimals: 2 + unit: "" + value: page.draft.sensitivity + disabledReason: page.disabledReason + onMoved: value => page.edit("sensitivity", value) + } + SliderRow { width: parent.width label: "Scroll speed" diff --git a/roles/desktop/files/quickshell/Settings/qmldir b/roles/desktop/files/quickshell/Settings/qmldir index b809fa6e..b8a96cb1 100644 --- a/roles/desktop/files/quickshell/Settings/qmldir +++ b/roles/desktop/files/quickshell/Settings/qmldir @@ -25,6 +25,7 @@ SwitchRow SwitchRow.qml PowerPage PowerPage.qml RegionPage RegionPage.qml TouchpadPage TouchpadPage.qml +KeyboardPage KeyboardPage.qml NightLightGroup NightLightGroup.qml RecoveryGroup RecoveryGroup.qml UndoChip UndoChip.qml diff --git a/roles/desktop/files/quickshell/safe-mode/shell.qml b/roles/desktop/files/quickshell/safe-mode/shell.qml new file mode 100644 index 00000000..218221fd --- /dev/null +++ b/roles/desktop/files/quickshell/safe-mode/shell.qml @@ -0,0 +1,91 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Wayland + +// Quickshell private PostReloadHook is absent from installed type metadata. +// qmllint disable import + +// Deliberately standalone: no settings, theme, plugins or connected services +// are constructed. A broken personal configuration stays untouched on disk. +ShellRoot { + id: root + // Recovery tokens cannot depend on a possibly broken Theme/Settings tree. + readonly property var typography: ({ control: 13, body: 14, caption: 12 }) + + function terminal() { Quickshell.execDetached(["kitty"]); } + function recover() { Quickshell.execDetached(["cybexos-runtime", "shell", "recover"]); } + + GlobalShortcut { + appid: "quickshell" + name: "launcherToggle" + description: "Open a recovery terminal" + onPressed: root.terminal() + } + IpcHandler { + target: "recovery" + function status(): string { return "safe"; } + function terminal(): void { root.terminal(); } + function retry(): void { root.recover(); } + } + // Existing keybindings remain usable in the recovery session. + IpcHandler { + target: "launcher" + function toggle(): void { root.terminal(); } + } + SystemClock { id: clock; precision: SystemClock.Minutes } + + component Action: Rectangle { + id: action + required property string label + signal triggered() + implicitWidth: caption.implicitWidth + 24 + implicitHeight: 30 + radius: 5 + color: activeFocus || pointer.containsMouse ? "#4c4b40" : "#35342f" + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: label + Accessible.onPressAction: triggered() + Keys.onReturnPressed: triggered() + Keys.onSpacePressed: triggered() + Text { id: caption; anchors.centerIn: parent; text: action.label; color: "#ffffff"; font.pixelSize: root.typography.control } + MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: action.triggered() } + } + + Variants { + model: Quickshell.screens + PanelWindow { + id: panel + required property var modelData + screen: modelData + anchors { top: true; left: true; right: true } + implicitHeight: 46 + color: "#23221e" + WlrLayershell.namespace: "qs-recovery" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + Row { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + Text { text: "CybexOS recovery"; color: "#e1df9a"; font.pixelSize: root.typography.body; height: 30; verticalAlignment: Text.AlignVCenter } + Action { label: "Terminal"; onTriggered: root.terminal() } + Action { label: "Retry desktop"; onTriggered: root.recover() } + Text { + visible: panel.width > 900 + text: "Widgets paused · Your settings are preserved · cybex shell status" + color: "#c9c7bd"; font.pixelSize: root.typography.caption; height: 30; verticalAlignment: Text.AlignVCenter + } + } + Text { + anchors.right: parent.right; anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + text: Qt.formatDateTime(clock.date, "HH:mm") + color: "#ffffff"; font.pixelSize: root.typography.body + } + } + } +} diff --git a/roles/desktop/files/quickshell/scripts/settings-store b/roles/desktop/files/quickshell/scripts/settings-store new file mode 100644 index 00000000..102d8d38 --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/settings-store @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Merge a settings edit with current disk contents and atomically commit it.""" +import fcntl +import hashlib +import json +import os +from pathlib import Path +import sys +import tempfile + +MISSING = object() +LIMIT = 2 * 1024 * 1024 + + +def parse(text): + value = json.loads(text) if text.strip() else {} + if not isinstance(value, dict): + raise ValueError('Settings must contain a JSON object') + return value + + +def merge(base, desired, current, path=''): + """Three-way merge: unrelated edits survive; conflicting edits are refused.""" + if desired == base or desired == current: + return current + if current == base: + return desired + if all(isinstance(value, dict) for value in (base, desired, current)): + out = dict(current) + for key in set(base) | set(desired): + # The caller separately verifies that the running schema supports + # the latest file; a concurrent migration to that schema is safe. + if not path and key == 'v': + out[key] = desired.get(key, current.get(key)) + continue + value = merge(base.get(key, MISSING), desired.get(key, MISSING), + current.get(key, MISSING), path + '/' + key) + if value is MISSING: + out.pop(key, None) + else: + out[key] = value + return out + raise ValueError('Another writer changed ' + (path or '/') + '; reload before retrying') + + +def backup(path, text, label): + saved = path.with_name(path.name + '.' + label + '-' + hashlib.sha256(text.encode()).hexdigest()[:16]) + try: + with saved.open('x') as stream: + os.fchmod(stream.fileno(), 0o600) + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + except FileExistsError: + pass + return str(saved) + + +def commit(path, baseline, candidate, version): + path = Path(path) + if path.is_symlink() or any(parent.is_symlink() for parent in path.parents): + raise ValueError('Settings path must not contain symlinks') + base, desired = parse(baseline), parse(candidate) + if desired.get('v') != version: + raise ValueError('Candidate settings schema does not match the running shell') + path.parent.mkdir(parents=True, exist_ok=True) + with path.with_name(path.name + '.lock').open('a') as lock: + os.fchmod(lock.fileno(), 0o600) + fcntl.flock(lock, fcntl.LOCK_EX) + for _attempt in range(3): + if path.exists() and path.stat().st_size > LIMIT: + raise ValueError('Settings file is too large') + current_text = path.read_text() if path.exists() else '' + current = parse(current_text) + if isinstance(current.get('v'), (int, float)) and current['v'] > version: + raise ValueError('Settings were saved by a newer shell; refusing to downgrade them') + try: + merged = merge(base, desired, current) + except ValueError as error: + saved = backup(path, candidate, 'conflict') + raise ValueError(str(error) + '. Your pending edit is saved at ' + saved) from error + text = json.dumps(merged, indent=2, ensure_ascii=False) + '\n' + if text == current_text: + return text + if current_text and current.get('v') != version: + backup(path, current_text, 'before-migration') + fd, temporary = tempfile.mkstemp(prefix='.shell-settings-', dir=path.parent) + try: + with os.fdopen(fd, 'w') as stream: + os.fchmod(stream.fileno(), 0o600) + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + # Cooperative writers are locked. A normal editor is not, so + # re-read immediately before publication and retry its edit. + if (path.read_text() if path.exists() else '') != current_text: + continue + os.replace(temporary, path) + directory = os.open(path.parent, os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + return text + finally: + Path(temporary).unlink(missing_ok=True) + raise ValueError('Settings changed repeatedly; retry after the other editor finishes') + + +def main(): + try: + request = json.loads(sys.stdin.readline(LIMIT + 1)) + text = commit(sys.argv[1], request['baseline'], request['candidate'], request['version']) + print(json.dumps({'ok': True, 'text': text})) + except (OSError, ValueError, KeyError, IndexError, TypeError) as error: + print(json.dumps({'ok': False, 'error': str(error)})) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/roles/desktop/files/quickshell/scripts/shell-recovery.py b/roles/desktop/files/quickshell/scripts/shell-recovery.py new file mode 100644 index 00000000..13c68728 --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/shell-recovery.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Crash-loop recovery for the managed shell, without modifying user settings. + +The launcher execs qs so systemd's MainPID remains the only shell PID. +ExecStopPost records only failed invocations. Three short failures in two +minutes select a separate minimal configuration until the user retries. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import fcntl +import json +import os +from pathlib import Path +import sys +import tempfile +import time +from typing import Any, Iterator + +FAILURE_WINDOW = 120.0 +FAILURE_LIMIT = 3 + + +def state_path() -> Path: + return Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "cybexos/shell-recovery.json" + + +def boot_id() -> str: + return Path("/proc/sys/kernel/random/boot_id").read_text().strip() + + +def read_state(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text()) + if not isinstance(value, dict) or value.get("version") != 1: + raise ValueError("invalid recovery state") + return value + except FileNotFoundError: + return {"version": 1, "safe": False, "failures": []} + except (ValueError, OSError): + # A damaged recovery record must not strand the desktop. It contains + # only disposable lifecycle data, never user settings or plugin data. + return {"version": 1, "safe": True, "failures": [], "reason": "Recovery state could not be read"} + + +def write_state(path: Path, value: dict[str, Any]) -> None: + fd, temporary = tempfile.mkstemp(prefix=".shell-recovery-", dir=path.parent) + try: + with os.fdopen(fd, "w") as stream: + json.dump(value, stream, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +@contextmanager +def state_lock(path: Path) -> Iterator[None]: + path.parent.mkdir(parents=True, exist_ok=True) + with (path.parent / ".shell-recovery.lock").open("a") as lock: + os.chmod(lock.name, 0o600) + fcntl.flock(lock, fcntl.LOCK_EX) + yield + + +def prepare(state: dict[str, Any], now: float, boot: str, invocation: str) -> dict[str, Any]: + state = dict(state) + if state.get("boot") != boot: + state["failures"] = [] + state.update(boot=boot, invocation=invocation, started=now, active=True) + return state + + +def record_stop(state: dict[str, Any], now: float, boot: str, invocation: str, + result: str, exit_code: str, exit_status: str) -> dict[str, Any]: + state = dict(state) + if state.get("boot") != boot or state.get("invocation") != invocation or not state.get("active"): + return state + state["active"] = False + state["lastExit"] = {"result": result, "code": exit_code, "status": exit_status} + elapsed = max(0.0, now - float(state.get("started", now))) + failures = [value for value in state.get("failures", []) + if isinstance(value, (int, float)) and 0 <= now - value <= FAILURE_WINDOW] + if result == "success" or elapsed > FAILURE_WINDOW: + failures = [] + elif result in {"exit-code", "signal", "core-dump", "timeout", "watchdog", "oom-kill"}: + failures.append(now) + state["failures"] = failures[-FAILURE_LIMIT:] + if len(failures) >= FAILURE_LIMIT: + state["safe"] = True + state["reason"] = "The desktop failed three times within two minutes" + return state + + +def selected_path(runtime: Path, state: dict[str, Any]) -> Path: + fallback = runtime / "safe-mode" + return fallback if state.get("safe") and (fallback / "shell.qml").is_file() else runtime + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["run", "record-stop", "status", "path", "safe", "recover"]) + parser.add_argument("runtime", type=Path) + args = parser.parse_args(argv) + path = state_path() + with state_lock(path): + state = read_state(path) + if args.action == "run": + state = prepare(state, time.monotonic(), boot_id(), os.environ.get("INVOCATION_ID", "")) + elif args.action == "record-stop": + state = record_stop(state, time.monotonic(), boot_id(), os.environ.get("INVOCATION_ID", ""), + os.environ.get("SERVICE_RESULT", ""), os.environ.get("EXIT_CODE", ""), + os.environ.get("EXIT_STATUS", "")) + elif args.action in {"safe", "recover"}: + # Invalidate the old invocation: its ExecStopPost must not undo + # this deliberate recovery choice during the following restart. + state.update(safe=args.action == "safe", failures=[], active=False, + reason="Safe mode requested" if args.action == "safe" else "") + if args.action not in {"status", "path"}: + write_state(path, state) + if args.action == "status": + print(json.dumps({**state, "path": str(path), "runtime": str(selected_path(args.runtime, state))}, sort_keys=True)) + elif args.action == "path": + print(selected_path(args.runtime, state)) + elif args.action == "run": + selected = selected_path(args.runtime, state) + if selected != args.runtime: + os.environ["CYBEXOS_SHELL_SAFE_MODE"] = "1" + print("cybexos: starting the recovery desktop; use cybex shell recover to retry", file=sys.stderr, flush=True) + os.execv("/usr/bin/qs", ["qs", "-p", str(selected)]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/roles/desktop/files/quickshell/scripts/system-settings.py b/roles/desktop/files/quickshell/scripts/system-settings.py index 46e34d56..f6afaa4b 100644 --- a/roles/desktop/files/quickshell/scripts/system-settings.py +++ b/roles/desktop/files/quickshell/scripts/system-settings.py @@ -6,11 +6,13 @@ from system_settings_audio import AudioSettings from system_settings_network import NetworkSettings from system_settings_accounts import AccountSettings +from system_settings_input import InputSettings +from system_settings_region import RegionSettings def main(): try: - if len(sys.argv) != 2 or sys.argv[1] not in ('sound', 'network', 'accounts'): + if len(sys.argv) != 2 or sys.argv[1] not in ('sound', 'network', 'accounts', 'input', 'region'): raise ValueError('Unknown settings service') raw = sys.stdin.buffer.readline(65537) if len(raw) > 65536: @@ -19,7 +21,8 @@ def main(): if not isinstance(request, dict): raise ValueError('Invalid settings request') service = {'sound': AudioSettings, 'network': NetworkSettings, - 'accounts': AccountSettings}[sys.argv[1]]() + 'accounts': AccountSettings, 'input': InputSettings, + 'region': RegionSettings}[sys.argv[1]]() result = service.dispatch(request) print(json.dumps({'success': True, **result}), flush=True) except ValueError as error: diff --git a/roles/desktop/files/quickshell/scripts/system_settings_input.py b/roles/desktop/files/quickshell/scripts/system_settings_input.py new file mode 100644 index 00000000..8941f6b1 --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/system_settings_input.py @@ -0,0 +1,205 @@ +"""Personal input preferences. Reloads preserve the final user.lua layer.""" +import copy +import fcntl +import hashlib +import json +import math +import os +from pathlib import Path +import re +import subprocess +import tempfile +import xml.etree.ElementTree as ET + +MAX_BYTES = 262144 +TOKEN = re.compile(r'^[A-Za-z0-9_-]{1,64}$') +SHORTCUTS = ('', 'grp:alt_shift_toggle', 'grp:ctrl_shift_toggle', 'grp:caps_toggle') + + +def run(args): + result = subprocess.run(args, capture_output=True, text=True, timeout=8, check=False) + if result.returncode: + raise ValueError('Hyprland could not apply this change. Check your session and retry.') + return result.stdout.strip() + + +def reject_duplicates(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError('Duplicate input preference key') + result[key] = value + return result + + +def validate(document): + if not isinstance(document, dict) or type(document.get('v')) not in (int, float) or document['v'] != 1: + raise ValueError('Unsupported input preferences version') + keyboard = document.get('keyboard', {}) + touchpad = document.get('touchpad', {}) + if not isinstance(keyboard, dict) or not isinstance(touchpad, dict): + raise ValueError('Invalid input preferences') + if 'layouts' in keyboard: + layouts = keyboard['layouts'] + if not isinstance(layouts, list) or not 1 <= len(layouts) <= 4: + raise ValueError('Choose between one and four keyboard layouts') + for entry in layouts: + if (not isinstance(entry, dict) or not isinstance(entry.get('layout'), str) + or not TOKEN.fullmatch(entry['layout']) or not isinstance(entry.get('variant', ''), str) + or (entry.get('variant') and not TOKEN.fullmatch(entry['variant']))): + raise ValueError('Invalid keyboard layout or variant') + if 'shortcut' in keyboard and keyboard['shortcut'] not in SHORTCUTS: + raise ValueError('Invalid layout switching shortcut') + for key in ('tap', 'naturalScroll'): + if key in touchpad and type(touchpad[key]) is not bool: + raise ValueError('Touchpad switches must be true or false') + if 'sensitivity' in touchpad: + value = touchpad['sensitivity'] + if type(value) not in (int, float) or not math.isfinite(value) or not -1 <= value <= 1: + raise ValueError('Touchpad sensitivity must be between -1 and 1') + return document + + +def catalog(path=Path('/usr/share/X11/xkb/rules/evdev.xml')): + result = [] + for layout in ET.parse(path).getroot().findall('./layoutList/layout'): + info = layout.find('configItem') + name = info.findtext('name', '') + if not TOKEN.fullmatch(name): + continue + variants = [{'value': '', 'label': 'Default'}] + for entry in layout.findall('./variantList/variant/configItem'): + variant = entry.findtext('name', '') + if TOKEN.fullmatch(variant): + variants.append({'value': variant, 'label': entry.findtext('description', variant)}) + result.append({'value': name, 'label': info.findtext('description', name), 'variants': variants}) + return result + + +class InputSettings: + def __init__(self): + self.path = Path(os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') / 'cybexos/input.json' + + def read(self): + if self.path.is_symlink(): + raise ValueError('Input preferences are a symlink; edit the linked file directly.') + try: + with self.path.open('rb') as source: + raw = source.read(MAX_BYTES + 1) + except FileNotFoundError: + raw = b'' + if len(raw) > MAX_BYTES: + raise ValueError('Input preferences are too large') + try: + document = validate(json.loads(raw, object_pairs_hook=reject_duplicates)) if raw else {'v': 1} + except (ValueError, UnicodeError, RecursionError) as error: + raise ValueError('Input preferences are invalid. Repair input.json before applying changes.') from error + return document, hashlib.sha256(raw).hexdigest(), raw + + def option(self, name, field): + value = json.loads(run(['hyprctl', '-j', 'getoption', 'input:' + name])) + if not isinstance(value, dict): + raise ValueError('Hyprland did not report its input configuration') + if field == 'bool': + # Current Hyprland reports Boolean options as JSON booleans; + # older releases encoded the same switches as integer 0/1. + # Do not coerce strings such as "false" into a true switch. + if 'bool' in value and type(value['bool']) is bool: + return value['bool'] + if 'bool' not in value and type(value.get('int')) is int and value['int'] in (0, 1): + return bool(value['int']) + raise ValueError('Hyprland did not report its input configuration') + if field not in value: + raise ValueError('Hyprland did not report its input configuration') + return value[field] + + def snapshot(self): + document, version, _ = self.read() + layouts = str(self.option('kb_layout', 'str')).split(',') + variants = str(self.option('kb_variant', 'str')).split(',') + options = str(self.option('kb_options', 'str')).split(',') + devices = json.loads(run(['hyprctl', '-j', 'devices'])) + return {'version': version, 'catalog': catalog(), 'preferences': document, + 'keyboard': {'layouts': [{'layout': name, 'variant': variants[index] if index < len(variants) else ''} + for index, name in enumerate(layouts)], + 'shortcut': next((option for option in options if option.startswith('grp:')), ''), + 'active': [{'name': item.get('name', ''), 'layout': item.get('active_keymap', '')} + for item in devices.get('keyboards', []) if item.get('main', False)]}, + 'touchpad': {'tap': self.option('touchpad:tap_to_click', 'bool'), + 'naturalScroll': self.option('touchpad:natural_scroll', 'bool'), + 'sensitivity': float(self.option('sensitivity', 'float'))}} + + def atomic_write(self, raw): + fd, name = tempfile.mkstemp(prefix='.input-', dir=self.path.parent) + try: + with os.fdopen(fd, 'wb') as target: + target.write(raw) + target.flush() + os.fsync(target.fileno()) + os.replace(name, self.path) + directory = os.open(self.path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(name): + os.unlink(name) + + def dispatch(self, request): + action = request.get('action', 'snapshot') + if action == 'snapshot': + return self.snapshot() + if action == 'switch': + run(['hyprctl', 'switchxkblayout', 'all', 'next']) + return {'message': 'Keyboard layout switched'} + if action != 'apply' or request.get('section') not in ('keyboard', 'touchpad'): + raise ValueError('Unknown input operation') + section = request['section'] + patch = request.get('values') + allowed = {'keyboard': {'layouts', 'shortcut'}, 'touchpad': {'tap', 'naturalScroll', 'sensitivity'}}[section] + if not isinstance(patch, dict) or not patch or set(patch) - allowed: + raise ValueError('Invalid input change') + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + lock_fd = os.open(self.path.with_suffix('.lock'), os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(lock_fd, 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + document, version, previous = self.read() + if request.get('version') != version: + raise ValueError('Input preferences changed elsewhere. Discard the pending edits and refresh before applying.') + candidate = copy.deepcopy(document) + candidate.setdefault(section, {}).update(patch) + validate(candidate) + if section == 'keyboard' and 'layouts' in patch: + available = {item['value']: {variant['value'] for variant in item['variants']} for item in catalog()} + for entry in patch['layouts']: + if entry.get('variant', '') not in available.get(entry['layout'], set()): + raise ValueError('That keyboard layout or variant is not installed') + # Preserve future per-layout metadata for unchanged identities. + old = {(entry['layout'], entry.get('variant', '')): entry for entry in document.get('keyboard', {}).get('layouts', [])} + candidate[section]['layouts'] = [{**old.get((entry['layout'], entry.get('variant', '')), {}), **entry} + for entry in patch['layouts']] + payload = (json.dumps(candidate, ensure_ascii=False, allow_nan=False, indent=2) + '\n').encode() + if len(payload) > MAX_BYTES: + raise ValueError('Input preferences are too large') + try: + self.atomic_write(payload) + output = run(['hyprctl', 'reload']) + if output.lower() != 'ok': + raise ValueError('Hyprland rejected the input configuration') + # The loader contains errors to keep the compositor alive; + # reload's acknowledgement alone therefore cannot prove success. + output = run(['hyprctl', 'eval', 'assert(__cybexos_input_error == nil, __cybexos_input_error)']) + if output.lower() != 'ok': + raise ValueError('Hyprland rejected the saved input preferences') + except Exception as error: + if previous: + self.atomic_write(previous) + else: + self.path.unlink(missing_ok=True) + try: + run(['hyprctl', 'reload']) + except Exception: + raise ValueError('Input preferences were restored, but Hyprland could not reload. Sign out to restore the session.') from error + raise ValueError('Hyprland could not apply the change. Previous preferences were restored.') from error + return {'message': 'Input preferences saved. Personal Hyprland overrides still take precedence.'} diff --git a/roles/desktop/files/quickshell/scripts/system_settings_region.py b/roles/desktop/files/quickshell/scripts/system_settings_region.py new file mode 100644 index 00000000..fd5c4bcb --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/system_settings_region.py @@ -0,0 +1,79 @@ +"""System region settings through timedated/localed's normal Polkit policy.""" +import os +import subprocess + +TIME = 'org.freedesktop.timedate1' +LOCALE = 'org.freedesktop.locale1' + + +def choices(command): + result = subprocess.run(command, capture_output=True, text=True, timeout=5, check=False, + env={**os.environ, 'LC_ALL': 'C', 'SYSTEMD_PAGER': ''}) + if result.returncode: + raise ValueError('System region choices are unavailable. Check systemd and language packages.') + return sorted(set(result.stdout.splitlines())) + + +class RegionSettings: + def __init__(self): + import gi + gi.require_version('Gio', '2.0') + from gi.repository import Gio, GLib + self.Gio, self.GLib = Gio, GLib + self.bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) + + def call(self, service, interface, method, signature, values, interactive=False): + try: + flags = (self.Gio.DBusCallFlags.ALLOW_INTERACTIVE_AUTHORIZATION if interactive + else self.Gio.DBusCallFlags.NONE) + return self.bus.call_sync(service, '/' + service.replace('.', '/'), interface, method, + self.GLib.Variant(signature, values), None, flags, + 45000 if interactive else 5000, None).unpack() + except self.GLib.Error as error: + # Do not expose arbitrary D-Bus error text; identify the actionable cases. + remote = self.Gio.DBusError.get_remote_error(error) or '' + if any(word in remote.lower() for word in ('accessdenied', 'notauthorized', 'authfailed', 'cancelled')): + raise ValueError('Authorization was cancelled or denied. Retry and approve the system prompt.') from error + raise ValueError('The system region service did not finish. Refresh to check the current value before retrying.') from error + + def properties(self, service): + return self.call(service, 'org.freedesktop.DBus.Properties', 'GetAll', '(s)', (service,))[0] + + def snapshot(self): + time = self.properties(TIME) + locale = self.properties(LOCALE).get('Locale', []) + return {'timezone': time.get('Timezone', ''), + 'locale': next((value[5:] for value in locale if value.startswith('LANG=')), ''), + 'localeValues': locale, + 'timezones': choices(['timedatectl', 'list-timezones', '--no-pager']), + 'locales': choices(['localectl', 'list-locales', '--no-pager'])} + + def dispatch(self, request): + action = request.get('action', 'snapshot') + if action == 'snapshot': + return self.snapshot() + if action == 'timezone': + value = request.get('value') + if not isinstance(value, str) or value not in choices(['timedatectl', 'list-timezones', '--no-pager']): + raise ValueError('Choose an installed timezone') + if request.get('previous') != self.properties(TIME).get('Timezone', ''): + raise ValueError('The timezone changed elsewhere. Discard the pending change and refresh before applying.') + self.call(TIME, TIME, 'SetTimezone', '(sb)', (value, True), interactive=True) + if self.properties(TIME).get('Timezone') != value: + raise ValueError('The timezone was not retained. Refresh before retrying.') + return {'message': 'System timezone updated'} + if action == 'locale': + value = request.get('value') + if not isinstance(value, str) or value not in choices(['localectl', 'list-locales', '--no-pager']): + raise ValueError('Choose an installed locale. Install its language pack first if it is missing.') + previous = self.properties(LOCALE).get('Locale', []) + if request.get('previous') != previous: + raise ValueError('System language settings changed elsewhere. Discard the pending change and refresh before applying.') + # SetLocale replaces the whole array. Keep LC_TIME, LC_NUMERIC and + # every other explicit category; only change the requested LANG. + values = [entry for entry in previous if not entry.startswith('LANG=')] + ['LANG=' + value] + self.call(LOCALE, LOCALE, 'SetLocale', '(asb)', (values, True), interactive=True) + if sorted(self.properties(LOCALE).get('Locale', [])) != sorted(values): + raise ValueError('The language settings were not retained. Refresh before retrying.') + return {'message': 'System language updated. Sign out and back in for applications to use it.'} + raise ValueError('Unknown region operation') diff --git a/roles/desktop/files/quickshell/shell.qml b/roles/desktop/files/quickshell/shell.qml index 3db3d63a..ca66af15 100644 --- a/roles/desktop/files/quickshell/shell.qml +++ b/roles/desktop/files/quickshell/shell.qml @@ -66,10 +66,13 @@ ShellRoot { Settings.closePanel(); } + // Read-only effective preferences for same-source installation audits. + function values(): string { return JSON.stringify(Settings.snapshot()); } + // Read-only lifecycle diagnostics; no device or account metadata. function status(): string { const services = {}; - for (const name of ["sound", "network", "accounts"]) { + for (const name of ["sound", "network", "accounts", "input", "region"]) { const service = SystemSettings[name]; services[name] = {loaded: service.loaded, busy: service.busy, loading: service.loading, watchers: service.watchers, diff --git a/roles/desktop/tasks/hermes-menubar.yml b/roles/desktop/tasks/hermes-menubar.yml index 0ae8de9e..a328bedc 100644 --- a/roles/desktop/tasks/hermes-menubar.yml +++ b/roles/desktop/tasks/hermes-menubar.yml @@ -138,6 +138,17 @@ notify: Restart Hermes menubar bridge tags: [quickshell, hermes-menubar] +- name: Install the remote Hermes domain modules + become: true + become_user: "{{ primary_user }}" + ansible.builtin.copy: + src: hermes-menubar-bridge/cybex_hermes/ + dest: "{{ primary_home }}/.local/libexec/cybex_hermes/" + mode: "0644" + directory_mode: "0755" + notify: Restart Hermes menubar bridge + tags: [quickshell, hermes-menubar] + - name: Install the remote Hermes menubar bridge user unit become: true become_user: "{{ primary_user }}" diff --git a/roles/desktop/tasks/main.yml b/roles/desktop/tasks/main.yml index dadca2ab..b1b0528c 100644 --- a/roles/desktop/tasks/main.yml +++ b/roles/desktop/tasks/main.yml @@ -118,6 +118,8 @@ # link telemetry. - NetworkManager - NetworkManager-libnm + - xkeyboard-config + - tzdata - glib2 - nm-connection-editor - pulseaudio-utils @@ -197,6 +199,7 @@ state: absent loop: - "{{ primary_home }}/.local/libexec/hermes-menubar-bridge" + - "{{ primary_home }}/.local/libexec/cybex_hermes" - "{{ primary_home }}/.config/systemd/user/hermes-menubar-bridge.service" - "{{ primary_home }}/.config/systemd/user/hyprland-session.target.wants/hermes-menubar-bridge.service" notify: Reload user systemd @@ -488,6 +491,7 @@ - bindings.lua - autostart.lua - displays.lua + - input_preferences.lua # This entrypoint requires every module above. Keeping it last makes an # empty live configuration valid at each observable deployment boundary. - hyprland.lua @@ -535,6 +539,20 @@ changed_when: true when: desktop_ibus_schema_override is changed +- name: Default Files SMB connections to WORKGROUP + ansible.builtin.copy: + src: 90-cybexos-smb.gschema.override + dest: /usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override + owner: root + group: root + mode: "0644" + register: desktop_smb_schema_override + +- name: Compile GSettings schemas after changing the SMB default + ansible.builtin.command: glib-compile-schemas /usr/share/glib-2.0/schemas + changed_when: true + when: desktop_smb_schema_override is changed + - name: Install the singleton session-lock user unit before its callers become: true become_user: "{{ primary_user }}" @@ -597,7 +615,7 @@ label: "{{ item.path }}" when: - not hypr_runtime_path_normalization_pending | bool - - item.path | basename not in ['features.lua', 'monitors.lua', 'input.lua', 'looknfeel.lua', 'bindings.lua', 'autostart.lua', 'displays.lua', 'hyprland.lua', 'hypridle.conf', 'hyprlock.conf'] + - item.path | basename not in ['features.lua', 'monitors.lua', 'input.lua', 'looknfeel.lua', 'bindings.lua', 'autostart.lua', 'displays.lua', 'input_preferences.lua', 'hyprland.lua', 'hypridle.conf', 'hyprlock.conf'] tags: [browser] - name: Install the guarded desktop runtime resolver diff --git a/roles/desktop/templates/input.lua.j2 b/roles/desktop/templates/input.lua.j2 index 9a12992b..918a4cdc 100644 --- a/roles/desktop/templates/input.lua.j2 +++ b/roles/desktop/templates/input.lua.j2 @@ -15,12 +15,15 @@ local function persisted_scroll_factor() return 1.0 end +-- Saved input preferences reuse the release's option defaults. +_G.__cybexos_vendor_keyboard_options = "compose:caps,lv3:ralt_switch" + hl.config({ input = { kb_layout = {{ machine_keyboard_layout | to_json }}, kb_variant = {{ machine_keyboard_variant | to_json }}, -- Keep left Alt available to applications (for example Codex Alt+Up). - kb_options = "compose:caps,lv3:ralt_switch", + kb_options = _G.__cybexos_vendor_keyboard_options, follow_mouse = 1, natural_scroll = true, repeat_rate = 40, diff --git a/roles/desktop/templates/quickshell.service.j2 b/roles/desktop/templates/quickshell.service.j2 index 17ac1a8d..2954737d 100644 --- a/roles/desktop/templates/quickshell.service.j2 +++ b/roles/desktop/templates/quickshell.service.j2 @@ -5,6 +5,7 @@ PartOf=hyprland-session.target [Service] Type=simple ExecStart={{ primary_home }}/.local/bin/cybexos-runtime exec quickshell +ExecStopPost={{ primary_home }}/.local/bin/cybexos-runtime shell record-stop Environment=PATH={{ primary_home }}/.local/bin:{{ primary_home }}/.npm-global/bin:/usr/local/bin:/usr/bin Environment=CYBEXOS_CONNECTED_WIDGETS={{ (features.connected_widgets | bool) | ternary('1', '0') }} Environment=CYBEXOS_DEVELOPER_TOOLS={{ (features.developer_tools | bool) | ternary('1', '0') }} diff --git a/roles/dotfiles/files/gitconfig b/roles/dotfiles/files/gitconfig new file mode 100644 index 00000000..cd6d7c0a --- /dev/null +++ b/roles/dotfiles/files/gitconfig @@ -0,0 +1,13 @@ +[core] + pager = delta +[interactive] + diffFilter = delta --color-only +[delta] + navigate = true + side-by-side = true +[credential "https://github.com"] + helper = + helper = !/usr/bin/gh auth git-credential +[credential "https://gist.github.com"] + helper = + helper = !/usr/bin/gh auth git-credential diff --git a/roles/dotfiles/files/ssh.conf b/roles/dotfiles/files/ssh.conf new file mode 100644 index 00000000..28867020 --- /dev/null +++ b/roles/dotfiles/files/ssh.conf @@ -0,0 +1,4 @@ +Host * + IdentityAgent ~/.1password/agent.sock + StrictHostKeyChecking accept-new + HashKnownHosts yes diff --git a/roles/dotfiles/tasks/personal.yml b/roles/dotfiles/tasks/personal.yml index 4782f467..3c269a3c 100644 --- a/roles/dotfiles/tasks/personal.yml +++ b/roles/dotfiles/tasks/personal.yml @@ -22,71 +22,61 @@ - name: Install the managed Kitty fragment become: true become_user: "{{ primary_user }}" - ansible.builtin.copy: - src: kitty.conf + cybexos_managed_file: + content: "{{ lookup('file', role_path + '/files/kitty.conf', rstrip=false) }}" dest: "{{ primary_home }}/.config/kitty/cybexos.conf" + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" mode: "0644" when: manage_personal_dotfiles | bool - name: Include the managed Kitty fragment without replacing user configuration become: true become_user: "{{ primary_user }}" - ansible.builtin.blockinfile: + cybexos_user_include: path: "{{ primary_home }}/.config/kitty/kitty.conf" - create: true - backup: true - mode: "0644" - marker: "# {mark} CYBEXOS MANAGED INCLUDE" - block: "include cybexos.conf" + kind: kitty + when: manage_personal_dotfiles | bool + +- name: Inspect personal Git credential helpers without changing them + become: true + become_user: "{{ primary_user }}" + cybexos_user_include: + path: "{{ primary_home }}/.gitconfig" + kind: git + inspect_git_credentials: true + register: dotfiles_git_credentials when: manage_personal_dotfiles | bool - name: Install optional managed Git preferences become: true become_user: "{{ primary_user }}" - ansible.builtin.copy: + cybexos_managed_file: dest: "{{ primary_home }}/.config/cybexos/gitconfig" + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" mode: "0644" - content: | - [core] - pager = delta - [interactive] - diffFilter = delta --color-only - [delta] - navigate = true - side-by-side = true - [credential "https://github.com"] - helper = - helper = !/usr/bin/gh auth git-credential - [credential "https://gist.github.com"] - helper = - helper = !/usr/bin/gh auth git-credential + baseline: "{{ lookup('file', role_path + '/files/gitconfig', rstrip=false) }}" + content: >- + {{ lookup('file', role_path + '/files/gitconfig', rstrip=false).split('[credential')[0] + if dotfiles_git_credentials.personal_credentials | bool + else lookup('file', role_path + '/files/gitconfig', rstrip=false) }} when: manage_personal_dotfiles | bool - name: Include managed Git preferences without replacing user identity become: true become_user: "{{ primary_user }}" - ansible.builtin.blockinfile: + cybexos_user_include: path: "{{ primary_home }}/.gitconfig" - create: true - backup: true - mode: "0644" - marker: "# {mark} CYBEXOS MANAGED INCLUDE" - block: | - [include] - path = ~/.config/cybexos/gitconfig + kind: git when: manage_personal_dotfiles | bool - name: Configure SSH to use the 1Password agent without private key references become: true become_user: "{{ primary_user }}" - ansible.builtin.copy: - dest: "{{ primary_home }}/.ssh/config.d/cybexos.conf" + cybexos_managed_file: + dest: "{{ primary_home }}/.config/cybexos/ssh.conf" + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" mode: "0600" - content: | - Host * - IdentityAgent ~/.1password/agent.sock - StrictHostKeyChecking accept-new - HashKnownHosts yes + content: "{{ lookup('file', role_path + '/files/ssh.conf', rstrip=false) }}" when: - manage_personal_dotfiles | bool - features.proprietary_apps | bool @@ -94,35 +84,46 @@ - name: Include managed SSH preferences without replacing existing hosts become: true become_user: "{{ primary_user }}" - ansible.builtin.blockinfile: + cybexos_user_include: path: "{{ primary_home }}/.ssh/config" - create: true - backup: true - mode: "0600" - insertbefore: BOF - marker: "# {mark} CYBEXOS MANAGED INCLUDE" - block: "Include ~/.ssh/config.d/cybexos.conf" + kind: ssh + register: dotfiles_ssh_include when: - manage_personal_dotfiles | bool - features.proprietary_apps | bool +# Retire only the byte-identical old default. Keeping vendor defaults inside +# config.d made a user's wildcard Include apply them before their own values. +- name: Retire the unedited legacy SSH fragment + become: true + become_user: "{{ primary_user }}" + cybexos_managed_file: + dest: "{{ primary_home }}/.ssh/config.d/cybexos.conf" + state: absent + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" + baseline: "{{ lookup('file', role_path + '/files/ssh.conf', rstrip=false) }}" + when: + - manage_personal_dotfiles | bool + - features.proprietary_apps | bool + - not dotfiles_ssh_include.preserved | default(true) | bool + - name: Remove the CybexOS SSH include when proprietary integration is deselected become: true become_user: "{{ primary_user }}" - ansible.builtin.blockinfile: + cybexos_user_include: path: "{{ primary_home }}/.ssh/config" - marker: "# {mark} CYBEXOS MANAGED INCLUDE" + kind: ssh state: absent - failed_when: false when: - not features.proprietary_apps | bool - apps_feature_owned.proprietary_apps | default(false) | bool -- name: Remove the scoped SSH preferences when proprietary integration is deselected +- name: Remove unedited scoped SSH preferences when proprietary integration is deselected become: true become_user: "{{ primary_user }}" - ansible.builtin.file: - path: "{{ primary_home }}/.ssh/config.d/cybexos.conf" + cybexos_managed_file: + dest: "{{ primary_home }}/.config/cybexos/ssh.conf" + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" state: absent when: - not features.proprietary_apps | bool @@ -133,6 +134,7 @@ become_user: "{{ primary_user }}" ansible.builtin.copy: dest: "{{ primary_home }}/.config/user-dirs.dirs" + force: false mode: "0644" content: | XDG_DESKTOP_DIR="$HOME/" diff --git a/roles/dotfiles/tasks/shell-defaults.yml b/roles/dotfiles/tasks/shell-defaults.yml index c220b232..3c5beeb2 100644 --- a/roles/dotfiles/tasks/shell-defaults.yml +++ b/roles/dotfiles/tasks/shell-defaults.yml @@ -10,8 +10,9 @@ - name: Install Fish shell configuration become: true become_user: "{{ primary_user }}" - ansible.builtin.copy: - src: fish-config.fish + cybexos_managed_file: + content: "{{ lookup('file', role_path + '/files/fish-config.fish', rstrip=false) }}" + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" dest: "{{ primary_home }}/.config/fish/conf.d/50-cybexos.fish" mode: "0644" tags: [shell-defaults] diff --git a/roles/dotfiles/templates/cybex.j2 b/roles/dotfiles/templates/cybex.j2 index 38f0990d..1a82a110 100644 --- a/roles/dotfiles/templates/cybex.j2 +++ b/roles/dotfiles/templates/cybex.j2 @@ -16,6 +16,9 @@ case $command_name in update) exec "$source_dir/update" "$@" ;; + upgrade-system) + exec sudo /usr/local/libexec/cybexos-major-upgrade "$@" + ;; agent) exec "$agent_command" "$@" ;; @@ -25,6 +28,9 @@ case $command_name in plugin) exec {{ (primary_home + '/.local/bin/cybexos-runtime') | quote }} plugin "$@" ;; + shell) + exec {{ (primary_home + '/.local/bin/cybexos-runtime') | quote }} shell "$@" + ;; verify|doctor) verify_scope=false for argument in "$@"; do @@ -50,9 +56,11 @@ Commands: install Install using saved choices, or start first-run setup configure Ask the installation questions again and apply the answers update Check for and apply CybexOS and system updates + upgrade-system Prepare, perform, or inspect a supported Fedora major upgrade agent Launch or choose the default AI coding agent dev Select, inspect, or disable a live development checkout plugin Install, update, clone, remove, or configure desktop plugins + shell Inspect shell health, enter safe mode, or retry the full desktop verify Verify repository and installed-system health doctor Alias for verify uninstall Remove project-managed configuration diff --git a/roles/uninstall/tasks/main.yml b/roles/uninstall/tasks/main.yml index 774b19b4..39defcb6 100644 --- a/roles/uninstall/tasks/main.yml +++ b/roles/uninstall/tasks/main.yml @@ -42,6 +42,9 @@ loop: - cybexos-btrfs-scrub.timer - cybexos-recovery-refresh.service + - cybexos-update-recover.service + - cybexos-major-upgrade-validate.timer + - cybexos-major-upgrade-validate.service - xps-haptic-touchpad.service - xps-ipu7-camera-init.service - xps-ipu7-camera.service @@ -135,6 +138,7 @@ - "{{ primary_home }}/.local/bin/dev-arch-shell" - "{{ primary_home }}/.local/bin/dev-debian-shell" - "{{ primary_home }}/.local/libexec/hermes-menubar-bridge" + - "{{ primary_home }}/.local/libexec/cybex_hermes" - "{{ primary_home }}/.local/libexec/cybexos-migrate-layering" - "{{ primary_home }}/.local/share/nautilus-python/extensions/localsend.py" - "{{ primary_home }}/.config/distrobox/distrobox.conf" @@ -169,6 +173,13 @@ - /usr/local/libexec/cybexos-hyprland-session-start - /usr/local/libexec/cybexos-session-action - /usr/local/libexec/cybexos-system-snapshot + - /usr/local/libexec/cybexos-update-transaction + - /usr/local/libexec/cybexos-update-bootstrap + - /usr/local/libexec/cybexos-update-recover + - /usr/local/libexec/cybexos-update-recover.service + - /usr/local/libexec/cybexos-update-recover-login.conf + - /usr/local/libexec/cybexos-vendor-paths.json + - /usr/local/libexec/cybexos-major-upgrade - /usr/local/sbin/cybexos-system-snapshot - /usr/local/libexec/cybexos-common.sh - /etc/sysctl.d/60-cybexos-hardening.conf @@ -179,6 +190,7 @@ - /etc/brave/policies/managed/cybexos.json - /etc/fonts/conf.d/49-cybexos-defaults.conf - /usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override + - /usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override - /usr/local/libexec/cybexos-github-release-install - /usr/local/libexec/cybexos-source-app-build - /usr/local/libexec/cybexos-android-sdk-update @@ -217,6 +229,14 @@ | selectattr('item', 'equalto', '/usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override') | selectattr('changed') | list | length > 0 +- name: Recompile GSettings schemas without the SMB workgroup override + ansible.builtin.command: glib-compile-schemas /usr/share/glib-2.0/schemas + changed_when: true + when: >- + uninstall_system_files.results + | selectattr('item', 'equalto', '/usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override') + | selectattr('changed') | list | length > 0 + # Recovery points themselves stay: they are data on the user's filesystem and # `cybexos-system-snapshot` can be reinstalled to restore from them. - name: Remove recovery booting from the boot chain @@ -227,6 +247,11 @@ - /etc/grub.d/42_cybexos_recovery - /etc/kernel/install.d/95-cybexos-recovery.install - /etc/systemd/system/cybexos-recovery-refresh.service + - /etc/systemd/system/cybexos-update-recover.service + - /etc/systemd/system/systemd-user-sessions.service.d/60-cybexos-update-recover.conf + - /etc/systemd/system/sddm.service.d/60-cybexos-update-recover.conf + - /etc/systemd/system/cybexos-major-upgrade-validate.service + - /etc/systemd/system/cybexos-major-upgrade-validate.timer - /usr/lib/dracut/modules.d/90cybexos-recovery - /etc/dracut.conf.d/90-cybexos-recovery.conf - /boot/grub2/cybexos-recovery.cfg @@ -238,12 +263,18 @@ cmd: grub2-mkconfig -o /boot/grub2/grub.cfg removes: /boot/grub2/grub.cfg changed_when: true - when: uninstall_recovery_boot.results[0] is changed + when: >- + uninstall_recovery_boot.results + | selectattr('item', 'equalto', '/etc/grub.d/42_cybexos_recovery') + | selectattr('changed') | list | length > 0 - name: Rebuild initramfs images without the recovery overlay module ansible.builtin.command: dracut --regenerate-all --force changed_when: true - when: uninstall_recovery_boot.results[3] is changed or uninstall_recovery_boot.results[4] is changed + when: >- + uninstall_recovery_boot.results + | selectattr('item', 'in', ['/usr/lib/dracut/modules.d/90cybexos-recovery', '/etc/dracut.conf.d/90-cybexos-recovery.conf']) + | selectattr('changed') | list | length > 0 - name: Remove the managed mpv defaults ansible.builtin.blockinfile: diff --git a/roles/xps-2026/defaults/main.yml b/roles/xps-2026/defaults/main.yml index f1c52377..d58cf30d 100644 --- a/roles/xps-2026/defaults/main.yml +++ b/roles/xps-2026/defaults/main.yml @@ -14,6 +14,12 @@ xps_2026_speaker_skus: xps_2026_vesa_backlight_edid_products: - "30e4" +# Panel self-refresh causes black flashes and a sink link CRC error on this +# measured XPS 14 panel. Bytes 8..11 identify LGD / product 0x07c6 (little +# endian product bytes); do not apply this to every LG panel or XPS chassis. +xps_2026_psr_disabled_panels: + - { sku: "0DB9", edid: "30e4c607" } + # Supported values are low, mid, and high. They map to the current Synaptics # HID scale used by the 2026 XPS touchpad (10, 50, and 100 respectively). xps_2026_haptic_intensity: high diff --git a/roles/xps-2026/tasks/display.yml b/roles/xps-2026/tasks/display.yml new file mode 100644 index 00000000..c653b51d --- /dev/null +++ b/roles/xps-2026/tasks/display.yml @@ -0,0 +1,74 @@ +--- +- name: Read the internal-panel manufacturer and product for the PSR quirk + ansible.builtin.command: + argv: + - /usr/bin/bash + - -c + - | + shopt -s nullglob + for edid in /sys/class/drm/card*-eDP-*/edid; do + [[ -r $edid ]] || continue + panel=$(/usr/bin/od -An -tx1 -j8 -N4 "$edid") || continue + printf '%s\n' "${panel//[[:space:]]/}" + exit 0 + done + exit 1 + register: xps_2026_psr_panel + changed_when: false + failed_when: false + check_mode: false + tags: [xps-2026, hardware, display] + +- name: Disable unreliable self-refresh only on the measured XPS panel + when: >- + {'sku': xps_2026_product_sku, + 'edid': xps_2026_psr_panel.stdout | default('') | trim} + in xps_2026_psr_disabled_panels + tags: [xps-2026, hardware, display] + block: + - name: Inspect every kernel entry for the panel self-refresh quirk + ansible.builtin.command: + argv: [grubby, --info=ALL] + register: xps_2026_psr_boot_entries + changed_when: false + check_mode: false + + - name: Disable Xe panel self-refresh in current and future boot entries + ansible.builtin.command: + argv: + - grubby + - --update-kernel=ALL + - --args=xe.enable_psr=0 + when: >- + (xps_2026_psr_boot_entries.stdout_lines + | select('match', '^args=') | list | length == 0) + or + (xps_2026_psr_boot_entries.stdout_lines + | select('match', '^args=') + | reject('search', '(^|[ "])xe[.]enable_psr=0([ "]|$)') + | list | length > 0) + changed_when: true + + - name: Verify every kernel entry has the panel self-refresh quirk + ansible.builtin.command: + argv: [grubby, --info=ALL] + register: xps_2026_psr_updated_entries + changed_when: false + check_mode: false + when: not ansible_check_mode + + - name: Refuse an incomplete panel self-refresh boot-entry update + ansible.builtin.assert: + that: + - >- + xps_2026_psr_updated_entries.stdout_lines + | select('match', '^args=') | list | length > 0 + - >- + xps_2026_psr_updated_entries.stdout_lines + | select('match', '^args=') + | reject('search', '(^|[ "])xe[.]enable_psr=0([ "]|$)') + | list | length == 0 + fail_msg: >- + The affected XPS panel needs xe.enable_psr=0 in every kernel entry. + quiet: true + when: not ansible_check_mode diff --git a/roles/xps-2026/tasks/main.yml b/roles/xps-2026/tasks/main.yml index eb7ace88..d1af677e 100644 --- a/roles/xps-2026/tasks/main.yml +++ b/roles/xps-2026/tasks/main.yml @@ -9,6 +9,10 @@ - xps_2026_is_supported | bool - xps_2026_needs_vesa_backlight | bool +- name: Select reliable refresh for the affected XPS internal panel + ansible.builtin.import_tasks: display.yml + when: xps_2026_is_supported | bool + - name: Install explicit Panther Lake media and firmware support ansible.builtin.import_tasks: packages.yml when: xps_2026_is_supported | bool diff --git a/tests/cybexos-update-run b/tests/cybexos-update-run index 9d394246..0ee133a4 100755 --- a/tests/cybexos-update-run +++ b/tests/cybexos-update-run @@ -159,13 +159,14 @@ apply_mock dnf \ apply_mock ansible-playbook \ 'set -euo pipefail' \ + 'umask >>"$MOCK_ANSIBLE_UMASK_LOG"' \ 'cat "$MOCK_RELEASE_CONFIG" >"$MOCK_ANSIBLE_CONFIG_LOG"' \ 'printf "%s\n" "$*" >"$MOCK_ANSIBLE_ARGS_LOG"' \ 'exit "${MOCK_ANSIBLE_RC:-0}"' # The snapshot helper has its own Btrfs fixture. Keep the durable-update test # on the supported non-Btrfs path so it never inspects or mutates the host FS. -apply_mock findmnt 'printf "ext4\n"' +apply_mock findmnt 'printf "%s\n" "${MOCK_ROOT_FS:-ext4}"' # A pre-rename installation has only this root-owned helper. Its compatible # contract must bridge the first run of the renamed updater. @@ -211,6 +212,7 @@ export MOCK_DNF_LOG="$fixture/dnf.log" export MOCK_DNF_UMASK_LOG="$fixture/dnf-umask.log" export MOCK_ANSIBLE_CONFIG_LOG="$fixture/ansible-config.log" export MOCK_ANSIBLE_ARGS_LOG="$fixture/ansible-args.log" +export MOCK_ANSIBLE_UMASK_LOG="$fixture/ansible-umask.log" export MOCK_LEGACY_SNAPSHOT_LOG="$fixture/legacy-snapshot.log" export MOCK_FIRMWARE_LOG="$fixture/firmware-helper.log" export MOCK_FIRMWARE_UMASK_LOG="$fixture/firmware-umask.log" @@ -593,7 +595,8 @@ recommended=$($backend status --json "$recommended_id") [[ $(stat -c %a "$XDG_STATE_HOME/cybexos/update/logs/$recommended_id/dnf.log") == 600 ]] # Release migration, snapshot, dnf, Flatpak, and firmware all run through # the umask wrapper. -[[ $(grep -Fc '"${privileged_package[@]}"' "$backend") -eq 5 ]] +# Assert effective umasks in the executed mocks; the number of protected +# phases grows as download, health and recovery steps are introduced. # A run without --firmware records that and never starts the helper. [[ $(jq -r .firmware <<<"$recommended") == false ]] [[ $(jq -r .firmwareDone <<<"$recommended") == false ]] @@ -912,6 +915,8 @@ $backend _worker "$release_id" --full --skip-tests --no-packages \ -- -e "@$release_config" [[ $(<"$release_config") == 'schema: committed' ]] [[ $(<"$MOCK_ANSIBLE_CONFIG_LOG") == 'schema: committed' ]] +[[ $(tail -n 1 "$MOCK_ANSIBLE_UMASK_LOG") =~ ^0?022$ ]] +[[ $(stat -c %a "$XDG_STATE_HOME/cybexos/update/logs/$release_id/ansible.log") == 600 ]] [[ $(readlink "$release_data/current") == releases/2.0.0 ]] [[ ! -d $release_data/releases/1.0.0 ]] assert_agent_skill_links @@ -1025,4 +1030,287 @@ assert_unrelated_skills # Only the four --firmware runs above ever started the helper. [[ $(wc -l <"$MOCK_FIRMWARE_LOG") -eq 4 ]] -printf 'Durable updater serializes starts, defers cancellation past package and firmware transactions, inhibits shutdown, owns release and agent-skill activation rollback, preserves boot-scoped reboot advice including staged firmware, and reads exact log windows\n' +# Protected Btrfs runs use the shared durable journal. These helpers model +# its public protocol; tests/update-transaction.py exercises the actual +# checkpoint, restore, retry, and personal-data boundaries on disk. +export MOCK_TRANSACTION_LOG="$fixture/transaction.log" +export MOCK_TRANSACTION_STATE="$fixture/transaction.state" +export MOCK_TRANSACTION_ARGS_LOG="$fixture/transaction-args.log" +export MOCK_BOOTSTRAP_LOG="$fixture/bootstrap-calls.log" +export MOCK_BOOTSTRAP_BUNDLE="$fixture/bootstrap-bundle" +cat >"$mock_libexec/cybexos-system-snapshot" <<'SNAPSHOT' +#!/usr/bin/env bash +printf '%s\n' "${MOCK_SNAPSHOT_OUTPUT-20260930T120000Z-123}" +exit "${MOCK_SNAPSHOT_RC:-0}" +SNAPSHOT +cat >"$mock_libexec/cybexos-update-transaction" <<'TRANSACTION' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$1" >>"$MOCK_TRANSACTION_LOG" +printf '%s %s\n' "$0" "$*" >>"$MOCK_TRANSACTION_ARGS_LOG" +printf 'transaction %s\n' "$1" >>"$MOCK_ORDER_LOG" +case $1 in + status) printf '{"state":"%s"}\n' "$(<"$MOCK_TRANSACTION_STATE")"; exit 0 ;; + begin) state=prepared ;; + applying) state=applying ;; + commit) state=validating ;; + abort) state=aborted ;; + rollback) state=rolled-back ;; + *) exit 64 ;; +esac +printf '%s\n' "$state" >"$MOCK_TRANSACTION_STATE" +[[ $1 != "${MOCK_TRANSACTION_FAILURE:-}" ]] || exit 42 +[[ $1 != commit ]] || printf 'committed\n' >"$MOCK_TRANSACTION_STATE" +TRANSACTION +cat >"$mock_libexec/cybexos-update-bootstrap" <<'BOOTSTRAP' +#!/usr/bin/python3 +# The helper's isolated suite owns filesystem installation. This mock models +# its public first-upgrade protocol, including a distinct prepared bundle. +import json +import os +import pathlib +import sys +action = sys.argv[1] +with open(os.environ['MOCK_BOOTSTRAP_LOG'], 'a') as stream: + stream.write(json.dumps(sys.argv[1:]) + '\n') +with open(os.environ['MOCK_ORDER_LOG'], 'a') as stream: + stream.write('bootstrap ' + action + '\n') +if action == 'ready': + assert sys.argv[2] == '--libexec' + assert pathlib.Path(sys.argv[3]).is_dir() + sys.exit(1 if os.environ.get('MOCK_BOOTSTRAP_REQUIRED') else 0) +elif action == 'prepare': + options = dict(zip(sys.argv[2::2], sys.argv[3::2])) + assert set(options) == {'--source', '--checkpoint', '--id'} + assert pathlib.Path(options['--source']).is_dir() + assert options['--checkpoint'] == '20260930T120000Z-123' + failure = int(os.environ.get('MOCK_BOOTSTRAP_PREPARE_RC', '0')) + if failure: + sys.exit(failure) + print(os.environ.get('MOCK_BOOTSTRAP_OUTPUT', json.dumps({ + 'snapshot': '20260930T120001Z-124', + 'transactionHelper': os.environ['MOCK_BOOTSTRAP_BUNDLE'] + '/cybexos-update-transaction'}))) +elif action == 'finalize': + assert sys.argv[2:] == ['--bundle', os.environ['MOCK_BOOTSTRAP_BUNDLE']] + assert pathlib.Path(sys.argv[0]).parent == pathlib.Path(os.environ['MOCK_BOOTSTRAP_BUNDLE']) + sys.exit(int(os.environ.get('MOCK_BOOTSTRAP_FINALIZE_RC', '0'))) +else: + sys.exit(64) +BOOTSTRAP +chmod 0755 "$mock_libexec/cybexos-system-snapshot" \ + "$mock_libexec/cybexos-update-transaction" "$mock_libexec/cybexos-update-bootstrap" +mkdir "$MOCK_BOOTSTRAP_BUNDLE" +cp "$mock_libexec/cybexos-update-transaction" "$mock_libexec/cybexos-update-bootstrap" \ + "$MOCK_BOOTSTRAP_BUNDLE/" + +protected_run() { + : >"$MOCK_TRANSACTION_LOG" + : >"$MOCK_TRANSACTION_ARGS_LOG" + : >"$MOCK_BOOTSTRAP_LOG" + local started + started=$($backend start --json "$@") + protected_id=$(jq -er .id <<<"$started") + if [[ -n ${MOCK_UNWRITABLE_RESULT:-} ]]; then + mkdir "$XDG_STATE_HOME/cybexos/update/logs/$protected_id/$MOCK_UNWRITABLE_RESULT.rc" + fi + protected_rc=0 + $backend _worker "$protected_id" "$@" || protected_rc=$? + protected_status=$($backend status --json "$protected_id") +} + +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +[[ $protected_rc == 0 ]] +[[ $(jq -r .transactionProtection <<<"$protected_status") == btrfs ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,commit ]] + +# A pre-feature installation obtains a second, protected checkpoint before +# any package download/application. Every transaction call uses the returned +# bundle, and that bundle is retired only after its commit succeeds. +export MOCK_BOOTSTRAP_REQUIRED=1 +: >"$MOCK_ORDER_LOG" +: >"$MOCK_DNF_LOG" +protected_run --no-flatpak +[[ $protected_rc == 0 ]] +[[ $(jq -r .snapshotId <<<"$protected_status") == 20260930T120001Z-124 ]] +[[ $(jq -sr 'map(.[0]) | join(",")' "$MOCK_BOOTSTRAP_LOG") == ready,prepare,finalize ]] +[[ $(paste -sd, "$MOCK_ORDER_LOG") == 'bootstrap ready,bootstrap prepare,transaction begin,dnf upgrade,transaction applying,dnf upgrade,transaction commit,bootstrap finalize' ]] +grep -Fxq "$MOCK_BOOTSTRAP_BUNDLE/cybexos-update-transaction begin $protected_id 20260930T120001Z-124 --uid ${CYBEXOS_UPDATE_OWNER_UID:-$UID}" \ + "$MOCK_TRANSACTION_ARGS_LOG" +[[ $(awk '{print $1}' "$MOCK_TRANSACTION_ARGS_LOG" | sort -u) \ + == "$MOCK_BOOTSTRAP_BUNDLE/cybexos-update-transaction" ]] +[[ $(jq -sr '.[1][2]' "$MOCK_BOOTSTRAP_LOG") == "$mock_libexec" ]] +[[ $(jq -sr '.[1][4]' "$MOCK_BOOTSTRAP_LOG") == 20260930T120000Z-123 ]] +[[ $(jq -sr '.[1][6]' "$MOCK_BOOTSTRAP_LOG") == "$protected_id" ]] + +# Bootstrap failure or an unusable response must stop before begin/DNF. +export MOCK_BOOTSTRAP_PREPARE_RC=48 +: >"$MOCK_DNF_LOG" +protected_run --no-flatpak +unset MOCK_BOOTSTRAP_PREPARE_RC +[[ $protected_rc == 48 ]] +[[ $(jq -r .phase <<<"$protected_status") == snapshot ]] +[[ ! -s $MOCK_DNF_LOG && ! -s $MOCK_TRANSACTION_LOG ]] +[[ $(jq -sr 'map(.[0]) | join(",")' "$MOCK_BOOTSTRAP_LOG") == ready,prepare ]] +same_checkpoint_response=$(jq -cn \ + --arg helper "$MOCK_BOOTSTRAP_BUNDLE/cybexos-update-transaction" \ + '{snapshot: "20260930T120000Z-123", transactionHelper: $helper}') +for bootstrap_output in '' 'invalid json' \ + "$same_checkpoint_response" \ + '{"snapshot":"20260930T120001Z-124","transactionHelper":"/missing-fixture-helper"}'; do + export MOCK_BOOTSTRAP_OUTPUT="$bootstrap_output" + : >"$MOCK_DNF_LOG" + protected_run --no-flatpak + [[ $protected_rc == 125 ]] + [[ $(jq -r .phase <<<"$protected_status") == snapshot ]] + [[ ! -s $MOCK_DNF_LOG && ! -s $MOCK_TRANSACTION_LOG ]] + [[ $(jq -sr 'map(.[0]) | join(",")' "$MOCK_BOOTSTRAP_LOG") == ready,prepare ]] +done +unset MOCK_BOOTSTRAP_OUTPUT + +# Failed health keeps the bootstrap available to boot-time recovery; it is +# never finalized after selecting the previous generation for restart. +export MOCK_TRANSACTION_FAILURE=commit +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_TRANSACTION_FAILURE +[[ $protected_rc == 1 ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,commit,status,rollback ]] +[[ $(jq -sr 'map(.[0]) | join(",")' "$MOCK_BOOTSTRAP_LOG") == ready,prepare ]] +unset MOCK_BOOTSTRAP_REQUIRED + +export MOCK_TRANSACTION_FAILURE=commit +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_TRANSACTION_FAILURE +[[ $protected_rc == 1 ]] +[[ $(jq -r .phase <<<"$protected_status") == health ]] +[[ $(jq -r .rollbackState <<<"$protected_status") == restart-required ]] +[[ $(jq -r .rebootRecommendation <<<"$protected_status") == recommended ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,commit,status,rollback ]] + +export MOCK_ANSIBLE_RC=42 +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_ANSIBLE_RC +[[ $protected_rc == 42 ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] + +export MOCK_DNF_UPGRADE_RC=47 +: >"$MOCK_DNF_LOG" +protected_run --no-flatpak +unset MOCK_DNF_UPGRADE_RC +[[ $protected_rc == 47 ]] +[[ $(jq -r .phase <<<"$protected_status") == download ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,status,abort ]] +grep -q -- '--downloadonly' "$MOCK_DNF_LOG" +if grep -v -- '--downloadonly' "$MOCK_DNF_LOG" | grep -q ' upgrade '; then + exit 1 +fi + +# The durable state wins if the applying transition was written but its +# final sync/response failed before the worker could set its local flag. +export MOCK_TRANSACTION_FAILURE=applying +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_TRANSACTION_FAILURE +[[ $protected_rc == 1 ]] +[[ $(jq -r .rollbackState <<<"$protected_status") == restart-required ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] + +# Empty/malformed snapshot output, unsupported skip responses, and failed +# result writes never permit packages to run without a checkpoint. +for snapshot_output in '' malformed 'skipped: unsafe fixture layout'; do + export MOCK_SNAPSHOT_OUTPUT="$snapshot_output" MOCK_ROOT_FS=btrfs + : >"$MOCK_DNF_LOG" + protected_run --no-flatpak + [[ $protected_rc == 125 ]] + [[ $(jq -r .phase <<<"$protected_status") == snapshot ]] + [[ ! -s $MOCK_DNF_LOG && ! -s $MOCK_TRANSACTION_LOG ]] +done +unset MOCK_SNAPSHOT_OUTPUT MOCK_ROOT_FS + +export MOCK_SNAPSHOT_RC=46 +protected_run --no-flatpak +unset MOCK_SNAPSHOT_RC +[[ $protected_rc == 46 ]] +[[ $(jq -r .phase <<<"$protected_status") == snapshot ]] +[[ ! -s $MOCK_TRANSACTION_LOG ]] + +export MOCK_UNWRITABLE_RESULT=snapshot +: >"$MOCK_DNF_LOG" +protected_run --no-flatpak +unset MOCK_UNWRITABLE_RESULT +[[ $protected_rc == 125 ]] +[[ $(jq -r .phase <<<"$protected_status") == snapshot ]] +[[ ! -s $MOCK_DNF_LOG && ! -s $MOCK_TRANSACTION_LOG ]] + +# A download can succeed while its result cannot be recorded. The worker +# aborts its prepared transaction and never starts the installation phase. +export MOCK_UNWRITABLE_RESULT=download +: >"$MOCK_DNF_LOG" +protected_run --no-flatpak +unset MOCK_UNWRITABLE_RESULT +[[ $protected_rc == 125 ]] +[[ $(jq -r .phase <<<"$protected_status") == download ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,status,abort ]] +if grep -v -- '--downloadonly' "$MOCK_DNF_LOG" | grep -q ' upgrade '; then + exit 1 +fi + +# The RPM reconciler may return zero after deferring work because another +# process holds its lock. Only durable ready status may reach commit. +export MOCK_RECONCILE_LOG="$fixture/reconcile-calls.log" +export MOCK_RECONCILE_STATUS_FILE="$fixture/reconcile-status.json" +cat >"$mock_libexec/cybexos-reconcile" <<'RECONCILE' +#!/usr/bin/env bash +printf '%s\n' "$1" >>"$MOCK_RECONCILE_LOG" +case $1 in + --retry) exit "${MOCK_RECONCILE_RC:-0}" ;; + --status) cat "$MOCK_RECONCILE_STATUS_FILE"; exit "${MOCK_RECONCILE_STATUS_RC:-0}" ;; + *) exit 64 ;; +esac +RECONCILE +chmod 0755 "$mock_libexec/cybexos-reconcile" +printf '{"state":"ready","pending":false,"version":"fixture","desiredVersion":"fixture","accounts":{"fixture":{"state":"ready","version":"fixture","uid":%s}}}\n' \ + "$UID" \ + >"$MOCK_RECONCILE_STATUS_FILE" +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +[[ $protected_rc == 0 ]] +[[ $(paste -sd, "$MOCK_RECONCILE_LOG") == --retry,--status ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,commit ]] + +export MOCK_RECONCILE_STATUS_RC=44 +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_RECONCILE_STATUS_RC +[[ $protected_rc == 125 ]] +[[ $(jq -r .phase <<<"$protected_status") == reconcile ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] + +if ((UID >= 1000)); then + printf '%s\n' '{"state":"ready","pending":false,"version":"same","desiredVersion":"same","accounts":{"unrelated":{"state":"ready","version":"same","uid":0}}}' \ + >"$MOCK_RECONCILE_STATUS_FILE" + protected_run --full --skip-tests --no-packages --repo "$defer_repo" + [[ $protected_rc == 125 ]] + [[ $(jq -r .phase <<<"$protected_status") == reconcile ]] + [[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] +fi + +for reconcile_status in \ + '{"state":"pending","pending":true}' \ + '{"state":"ready","pending":true,"version":"old","desiredVersion":"new"}' \ + '{"state":"ready","pending":false,"version":"old","desiredVersion":"new"}' \ + '{"state":"ready","pending":false,"version":"same","desiredVersion":"same","accounts":{}}' \ + '{"state":"ready","pending":false,"version":"same","desiredVersion":"same","accounts":{"fixture":{"state":"error"}}}' \ + '{"state":"ready","pending":false,"version":"same","desiredVersion":"same","accounts":{"fixture":{"state":"ready","version":"old"}}}' \ + '{} invalid'; do + printf '%s\n' "$reconcile_status" >"$MOCK_RECONCILE_STATUS_FILE" + protected_run --full --skip-tests --no-packages --repo "$defer_repo" + [[ $protected_rc == 125 ]] + [[ $(jq -r .phase <<<"$protected_status") == reconcile ]] + [[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] +done + +export MOCK_RECONCILE_RC=43 +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_RECONCILE_RC +[[ $protected_rc == 43 ]] +[[ $(jq -r .phase <<<"$protected_status") == reconcile ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] + +printf 'Durable updater serializes starts, defers cancellation past package and firmware transactions, inhibits shutdown, owns release and agent-skill activation rollback, preserves boot-scoped reboot advice, requires valid checkpoints/results and current reconciled policy, and reads exact log windows\n' diff --git a/tests/hermes-bridge.py b/tests/hermes-bridge.py index b129fba1..b64c95f4 100644 --- a/tests/hermes-bridge.py +++ b/tests/hermes-bridge.py @@ -9,6 +9,8 @@ import os from pathlib import Path import queue +import shutil +import subprocess import stat import sys import tempfile @@ -20,6 +22,7 @@ ROOT = Path(__file__).resolve().parents[1] BRIDGE_PATH = ROOT / "roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py" +sys.path.insert(0, str(BRIDGE_PATH.parent)) SPEC = importlib.util.spec_from_file_location("hermes_menubar_bridge", BRIDGE_PATH) assert SPEC and SPEC.loader BRIDGE = importlib.util.module_from_spec(SPEC) @@ -583,7 +586,22 @@ def unit_restart_policy() -> None: assert "network-online.target" not in unit +def packaged_entrypoint() -> None: + """The renamed installed executable resolves its sibling package alone.""" + with tempfile.TemporaryDirectory(prefix="cybexos-hermes-package.") as scratch: + directory = Path(scratch) + entry = directory / "cybexos-hermes-menubar-bridge" + shutil.copy2(BRIDGE_PATH, entry) + shutil.copytree(BRIDGE_PATH.parent / "cybex_hermes", directory / "cybex_hermes") + env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1") + env.pop("PYTHONPATH", None) + result = subprocess.run([sys.executable, "-B", str(entry), "--help"], + cwd=directory, env=env, capture_output=True, text=True, check=True) + assert "--remote-only" in result.stdout + + if __name__ == "__main__": + packaged_entrypoint() unit_restart_policy() asyncio.run(scenario()) asyncio.run(delivery_scenario()) diff --git a/tests/hermes-remote-runtime.py b/tests/hermes-remote-runtime.py index 7047656b..e76cef5d 100644 --- a/tests/hermes-remote-runtime.py +++ b/tests/hermes-remote-runtime.py @@ -29,6 +29,7 @@ ROOT = Path(__file__).resolve().parents[1] BRIDGE_PATH = ROOT / "roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py" +sys.path.insert(0, str(BRIDGE_PATH.parent)) SPEC = importlib.util.spec_from_file_location( "hermes_remote_runtime_fixture", BRIDGE_PATH ) diff --git a/tests/hermes-webui-auth.py b/tests/hermes-webui-auth.py index 9a4f7e69..dc70ab61 100644 --- a/tests/hermes-webui-auth.py +++ b/tests/hermes-webui-auth.py @@ -19,6 +19,7 @@ ROOT = Path(__file__).resolve().parents[1] BRIDGE_PATH = ROOT / "roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py" +sys.path.insert(0, str(BRIDGE_PATH.parent)) SPEC = importlib.util.spec_from_file_location("hermes_remote_auth_fixture", BRIDGE_PATH) assert SPEC and SPEC.loader BRIDGE = importlib.util.module_from_spec(SPEC) diff --git a/tests/hyprland-features b/tests/hyprland-features index e3c4afa2..c3da2a03 100755 --- a/tests/hyprland-features +++ b/tests/hyprland-features @@ -267,7 +267,7 @@ install_feature_provider() { install_leaf_modules() { local destination=$1 scenario_root=$2 module - for module in monitors.lua input.lua looknfeel.lua bindings.lua autostart.lua displays.lua; do + for module in monitors.lua input.lua looknfeel.lua bindings.lua autostart.lua displays.lua input_preferences.lua; do install_hypr_module "$module" "$destination/$module" if [[ -f $destination/hyprland.lua ]]; then assert_hypr_tree_loadable "$scenario_root" "after $module" diff --git a/tests/hyprland-input b/tests/hyprland-input new file mode 100755 index 00000000..367401c9 --- /dev/null +++ b/tests/hyprland-input @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Runs the production data loader with a recording compositor; no live reload. +set -euo pipefail +repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +scratch=$(mktemp -d "${TMPDIR:-/tmp}/cybexos-input-lua.XXXXXX") +trap 'rm -rf -- "$scratch"' EXIT +mkdir -p "$scratch/config/cybexos" +cat > "$scratch/test.lua" <<'LUA' +local repo = assert(os.getenv("CYBEXOS_INPUT_TEST_REPO")) +package.path = repo .. "/roles/desktop/files/?.lua;" .. package.path +local calls = {} +hl = {config = function(value) calls[#calls + 1] = value end} +_G.__cybexos_vendor_keyboard_options = "compose:caps,lv3:ralt_switch,future:vendor" +local input = require("input_preferences") +local json = require("displays") +assert(#calls == 0, "missing preferences must not reset defaults") +input.apply(assert(json.decode([[{"v":1,"future":true,"keyboard":{"layouts":[{"layout":"us","variant":"intl"},{"layout":"nl"}],"shortcut":"grp:alt_shift_toggle"},"touchpad":{"tap":false,"naturalScroll":true,"sensitivity":0.25}}]]))) +local value = calls[#calls].input +assert(value.kb_layout == "us,nl" and value.kb_variant == "intl,") +assert(value.kb_options == "compose:caps,lv3:ralt_switch,future:vendor,grp:alt_shift_toggle", "shared defaults survive") +assert(value.touchpad.tap_to_click == false and value.touchpad.natural_scroll == true and value.sensitivity == 0.25) +input.apply(assert(json.decode('{"v":1,"keyboard":{"shortcut":"grp:caps_toggle"}}'))) +assert(not calls[#calls].input.kb_options:find("compose:caps", 1, true), "Caps shortcut cannot also be Compose") +local count = #calls +for _, text in ipairs({ + '{"v":1,"keyboard":false}', '{"v":1,"touchpad":[]}', '{"v":2}', + '{"v":1,"keyboard":{"layouts":[]}}', '{"v":1,"touchpad":{"tap":1}}', + '{"v":1,"touchpad":{"sensitivity":2}}', '{"v":1,"keyboard":{"shortcut":"bad"}}', + '{"v":1,"keyboard":{"layouts":[{"layout":"us\\\";os.execute(1)"}]}}' +}) do + local ok = pcall(input.apply, assert(json.decode(text))) + assert(not ok, "invalid document accepted: " .. text) + assert(#calls == count, "invalid preferences partially applied") +end +local path = assert(os.getenv("XDG_CONFIG_HOME")) .. "/cybexos/input.json" +local file = assert(io.open(path, "w")); file:write('{"v":1,"touchpad":{"tap":false}}'); file:close() +package.loaded.input_preferences = nil +require("input_preferences") +assert(calls[#calls].input.touchpad.tap_to_click == false, "saved preferences load at startup") +assert(_G.__cybexos_input_error == nil) +file = assert(io.open(path, "w")); file:write('{"v":1,"touchpad":'); file:close() +count = #calls +package.loaded.input_preferences = nil +require("input_preferences") +assert(#calls == count and type(_G.__cybexos_input_error) == "string", "corrupt preferences are contained") +print("PASS input preferences: persistence, validation, shared defaults and corruption recovery") +LUA +CYBEXOS_INPUT_TEST_REPO="$repo" XDG_CONFIG_HOME="$scratch/config" luajit "$scratch/test.lua" diff --git a/tests/installation-parity.py b/tests/installation-parity.py index af41c1ac..65ad26e9 100644 --- a/tests/installation-parity.py +++ b/tests/installation-parity.py @@ -7,4 +7,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'image')) if __name__ == '__main__': - unittest.main(module='test_installation_parity') + suite = unittest.defaultTestLoader.loadTestsFromNames([ + 'test_installation_parity', 'test_display_policy', 'test_update_recovery', + ]) + result = unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(not result.wasSuccessful()) diff --git a/tests/major-upgrade.py b/tests/major-upgrade.py new file mode 100644 index 00000000..c791526b --- /dev/null +++ b/tests/major-upgrade.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Major upgrade fault/recovery fixtures. No host packages, snapshots or services.""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import pwd +import subprocess +import tarfile +import tempfile +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +LOADER = importlib.machinery.SourceFileLoader('major_upgrade', str(ROOT / 'roles/base/files/cybexos-major-upgrade')) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +M = importlib.util.module_from_spec(SPEC) +LOADER.exec_module(M) + + +class UpgradeTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='cybexos-major-test.') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + for name, path in {'STATE': self.root/'state', 'PAYLOADS': self.root/'payloads', + 'CONFIG': self.root/'config.yml', 'OFFLINE': self.root/'offline', + 'TRIGGER': self.root/'system-update'}.items(): + patcher = patch.object(M, name, path) + patcher.start() + self.addCleanup(patcher.stop) + M.CONFIG.write_text('config_schema_version: 1\nprimary_user: fixture\n') + self.source = self.root/'source' + (self.source/'inventory/group_vars').mkdir(parents=True) + self.manifest = {'product':'cybexos', 'supportedFedora':['45'], + 'architectures':[M.platform.machine()], 'configSchema':1} + (self.source/'release-manifest.json').write_text(json.dumps(self.manifest)) + (self.source/'inventory/group_vars/all.yml').write_text('fedora_release: "45"\n') + for name in ('site.yml', 'ansible.cfg', 'inventory/hosts.yml', 'verify'): + (self.source/name).write_text('fixture\n') + self.account = pwd.getpwuid(os.getuid()) + self.calls = [] + self.transitions = [] + + def baseline(self): + return {'currentFedora':'44', 'targetFedora':'45', 'uid':self.account.pw_uid, + 'home':self.account.pw_dir, 'secureBoot':'legacy boot', 'hardwareChecks':[]} + + def args(self): + return argparse.Namespace(target='45', source=self.source, rpm=None, backup=None, uid=self.account.pw_uid) + + def command(self, command, **_kwargs): + values = list(map(str, command)) + self.calls.append(values) + if values[0] == str(M.SNAPSHOT): + return '20300101T000000-123' + if values[0] == 'dnf5' and 'download' in values: + M.OFFLINE.mkdir(exist_ok=True) + (M.OFFLINE/'transaction.json').write_text('{"target":"45"}') + return '' + + def transaction(self, action, identifier, *extra): + self.transitions.append((action, identifier, extra)) + return {'state':'awaiting-upgrade' if action == 'status' else action, 'id':identifier} + + def prepared(self): + with patch.object(M, 'preflight', return_value=self.baseline()), patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + return M.prepare(self.args()) + + def ready(self): + record = self.prepared() + with patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + return M.download(record['id']) + + def test_current_manifest_does_not_advertise_the_next_release(self): + with self.assertRaises(M.Failure): + M.compatible(json.loads((ROOT/'release-manifest.json').read_text()), '45', M.platform.machine(), 1) + for changed in ({'architectures':['aarch64']}, {'configSchema':2}, {'supportedFedora':['44']}): + with self.assertRaises(M.Failure): + M.compatible({**self.manifest, **changed}, '45', M.platform.machine(), 1) + + def test_source_inventory_must_agree_and_symlinks_cannot_escape(self): + (self.source/'inventory/group_vars/all.yml').write_text('fedora_release: "44"\n') + with self.assertRaisesRegex(M.Failure, 'disagree'): + M.inspect_target('45', self.source) + (self.source/'inventory/group_vars/all.yml').write_text('fedora_release: "45"\n') + (self.source/'outside').symlink_to(self.root/'config.yml') + with self.assertRaisesRegex(M.Failure, 'escapes'): + M.inspect_target('45', self.source) + + def test_signed_rpm_requires_target_capability_and_architecture(self): + rpm = self.root/'target.rpm' + rpm.write_bytes(b'fixture') + with patch.object(M, 'run', return_value='digests signatures NOT OK NOKEY'): + with self.assertRaises(M.Failure): + M.inspect_target('45', rpm=rpm) + identity = 'cybexos-desktop\n1.fc45\n' + M.platform.machine() + '\n1.0.0' + with patch.object(M, 'run', side_effect=['digests signatures OK', identity, 'cybexos-supported-fedora = 44']): + with self.assertRaisesRegex(M.Failure, 'declare support'): + M.inspect_target('45', rpm=rpm) + with patch.object(M, 'run', side_effect=['digests signatures OK', identity, 'cybexos-supported-fedora = 45']): + self.assertEqual(M.inspect_target('45', rpm=rpm)['kind'], 'rpm') + + def test_prepare_checkpoints_before_a_durable_download_and_preserves_configuration(self): + before = M.CONFIG.read_bytes() + record = self.prepared() + self.assertEqual(record['state'], 'downloading') + self.assertEqual([x[0] for x in self.transitions], ['begin']) + self.assertFalse(any('download' in command and command[0]=='dnf5' for command in self.calls)) + self.assertEqual(self.calls[-1][0], 'systemd-run') + self.assertIn('--property=KillMode=control-group', self.calls[-1]) + self.assertEqual(M.CONFIG.read_bytes(), before) + self.assertEqual(M.tree_digest(Path(record['candidate']['path'])), record['candidate']['digest']) + self.assertEqual((M.PAYLOADS/record['id']).stat().st_mode & 0o777, 0o755) + + def test_download_arms_only_after_success_and_never_reboots(self): + record = self.ready() + self.assertEqual(record['state'], 'ready') + self.assertEqual(self.transitions[-1][0], 'arm-upgrade') + command = next(x for x in self.calls if x[0]=='dnf5') + self.assertIn('--releasever=45', command) + self.assertIn('--setopt=gpgcheck=true', command) + self.assertNotIn('--allowerasing', command) + self.assertFalse(any('reboot' in x for x in self.calls)) + self.assertFalse(record['rebootRequested']) + + def test_cancel_cannot_interrupt_download_or_delete_replaced_offline_data(self): + record = self.prepared() + with self.assertRaises(M.Failure): + M.ensure_ours(record) + with patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + record = M.download(record['id']) + (M.OFFLINE/'transaction.json').write_text('someone else') + with self.assertRaisesRegex(M.Failure, 'changed'): + M.cancel() + self.assertTrue((M.PAYLOADS/record['id']).exists()) + + def test_cancel_only_cleans_its_completed_download(self): + record = self.ready() + with patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + result = M.cancel() + self.assertEqual(result['state'], 'cancelled') + self.assertEqual(self.transitions[-1][0], 'abort') + self.assertFalse((M.PAYLOADS/record['id']).exists()) + self.assertIn(['dnf5','offline','clean'], self.calls) + + def test_normal_reboot_preserves_safe_cancellation_of_owned_download(self): + record = self.ready() + with patch.object(M, 'boot_id', return_value='another-normal-boot'), patch.object(M, 'fedora_release', return_value='44'), patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + result = M.cancel() + self.assertEqual(result['state'], 'cancelled') + self.assertIn(['dnf5', 'offline', 'clean'], self.calls) + self.assertFalse((M.PAYLOADS/record['id']).exists()) + + def test_failed_download_can_be_cleaned_after_boot_already_aborted_checkpoint(self): + record = self.ready() + record['state'] = 'download-failed' + M.write(record) + with patch.object(M, 'boot_id', return_value='another-normal-boot'), patch.object(M, 'fedora_release', return_value='44'), patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', return_value={'state':'aborted'}) as transaction: + result = M.cancel() + self.assertEqual(result['state'], 'cancelled') + transaction.assert_called_once_with('status', record['id']) + self.assertIn(['dnf5', 'offline', 'clean'], self.calls) + self.assertFalse((M.PAYLOADS/record['id']).exists()) + + def test_offline_reboot_rebases_boot_boundary_and_still_checks_ownership(self): + record = self.ready() + with patch.object(M, 'boot_id', return_value='another-normal-boot'), patch.object(M, 'fedora_release', return_value='45'): + with self.assertRaisesRegex(M.Failure, 'release changed'): + M.reboot() + with patch.object(M, 'boot_id', return_value='another-normal-boot'), patch.object(M, 'fedora_release', return_value='44'), patch.object(M, 'run', side_effect=self.command): + M.TRIGGER.symlink_to('/missing/offline/trigger') + with self.assertRaisesRegex(M.Failure, 'already scheduled'): + M.reboot() + M.TRIGGER.unlink() + result = M.reboot() + self.assertEqual(result['bootId'], 'another-normal-boot') + self.assertTrue(result['rebootRequested']) + with patch.object(M, 'transaction', return_value={'state':'awaiting-upgrade'}), patch.object(M, 'converge') as converge: + M.finalize(record['id']) + converge.assert_not_called() + self.assertIn(['dnf5', 'offline', 'reboot'], self.calls) + + def test_rpm_reconciliation_requires_current_ready_accounts(self): + rpm = self.root/'target.rpm' + rpm.write_bytes(b'fixture') + record = {**self.baseline(), 'candidate':{'kind':'rpm', 'path':str(rpm), 'digest':M.digest(rpm)}} + ready = {'state':'ready', 'pending':False, 'version':'new', 'desiredVersion':'new', + 'accounts':{self.account.pw_name:{'state':'ready', 'version':'new', 'uid':self.account.pw_uid}}} + cases = [ready, {**ready, 'pending':True}, {**ready, 'state':'pending'}, + {**ready, 'version':'old'}, {**ready, 'accounts':{}}, + {**ready, 'accounts':{'other':{'state':'ready', 'version':'new', 'uid':-1}}}, + {**ready, 'accounts':{self.account.pw_name:{'state':'pending', 'version':'new', 'uid':self.account.pw_uid}}}, + {**ready, 'accounts':{self.account.pw_name:{'state':'ready', 'version':'old', 'uid':self.account.pw_uid}}}] + for value in cases: + with self.subTest(status=value), patch.object(M, 'inspect_target'), patch.object(M, 'run', side_effect=lambda args, **kw: json.dumps(value) if '--status' in args else ''): + if value == ready: + M.converge(record) + else: + with self.assertRaisesRegex(M.Failure, 'reconciliation'): + M.converge(record) + + def test_preexisting_offline_data_is_never_claimed_by_failed_worker(self): + record = self.prepared() + M.OFFLINE.mkdir() + (M.OFFLINE/'other.json').write_text('other transaction') + with patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + with self.assertRaises(M.Failure): + M.download(record['id']) + self.assertEqual(M.status()['state'], 'failed') + self.assertEqual(self.transitions[-1][0], 'abort') + self.assertTrue((M.OFFLINE/'other.json').exists()) + self.assertFalse(any(x[:3]==['dnf5','offline','clean'] for x in self.calls)) + + def test_offline_failure_rolls_back_before_any_convergence(self): + record = self.ready() + record.update(rebootRequested=True, bootId='previous-boot') + M.write(record) + def transition(action, identifier, *args): + return {'state':'awaiting-upgrade'} if action=='status' else self.transaction(action,identifier,*args) + with patch.object(M, 'transaction', side_effect=transition), patch.object(M, 'fedora_release', return_value='44'), patch.object(M, 'converge') as converge: + result, code = M.finalize(record['id']) + self.assertEqual(code,75) + self.assertEqual(result['state'],'rolled-back') + converge.assert_not_called() + self.assertEqual(self.transitions[-1][0], 'rollback') + + def test_root_validation_waits_for_real_desktop_before_commit(self): + record = self.ready() + record.update(rebootRequested=True, bootId='previous-boot') + M.write(record) + def transition(action, identifier, *args): + return {'state':'awaiting-upgrade'} if action=='status' else self.transaction(action,identifier,*args) + with patch.object(M, 'transaction', side_effect=transition), patch.object(M, 'fedora_release', return_value='45'), patch.object(M.platform, 'release', return_value='7.0.0-1.fc45.x86_64'), patch.object(M, 'converge'): + result, code = M.finalize(record['id']) + self.assertEqual(code,0) + self.assertEqual(result['state'],'awaiting-desktop') + self.assertEqual([x[0] for x in self.transitions[-2:]],['applying','await-desktop']) + self.assertNotIn('commit',[x[0] for x in self.transitions]) + + def test_desktop_validation_commits_or_selects_rollback_after_session_starts(self): + record = self.ready() + record['state'] = 'awaiting-desktop' + M.write(record) + original_exists = Path.exists + def bus_exists(path): + return str(path) == f'/run/user/{self.account.pw_uid}/bus' or original_exists(path) + for healthy in (True, False): + M.write(record) + self.transitions.clear() + def transition(action, identifier, *args): + if action == 'status': + return {'state':'awaiting-desktop'} + if action == 'commit' and not healthy: + raise M.Failure('Desktop failed its health check') + return self.transaction(action,identifier,*args) + with self.subTest(healthy=healthy), patch.object(Path, 'exists', bus_exists), patch.object(M, 'transaction', side_effect=transition), patch.object(M, 'run', side_effect=self.command): + result, code = M.finalize(record['id']) + self.assertEqual(code, 0 if healthy else 75) + self.assertEqual(result['state'], 'committed' if healthy else 'rolled-back') + self.assertEqual(self.transitions[-1][0], 'commit' if healthy else 'rollback') + self.assertTrue((M.PAYLOADS/record['id']).exists()) + if not healthy: + self.assertTrue(result['restartRequired']) + + def test_validation_timer_skips_busy_lock_without_failing_unit(self): + record = self.ready() + record['state'] = 'awaiting-desktop' + M.write(record) + with patch.object(M.os, 'geteuid', return_value=0), patch.object(M.os, 'umask'), patch.object(M.signal, 'signal'), patch.object(M, 'operation_lock', side_effect=M.Busy('busy')): + self.assertEqual(M.main(['finalize-current']), 0) + + def test_post_commit_failures_retry_bookkeeping_without_rollback(self): + for fault in ('status-write', 'payload-delete', 'command-output'): + if M.OFFLINE.exists(): + M.shutil.rmtree(M.OFFLINE) + record = self.ready() + record['state'] = 'awaiting-desktop' + record['candidate']['kind'] = 'rpm' + M.write(record) + durable = {'state':'awaiting-desktop', 'injected':False} + original_write, original_delete, original_exists = M.write, M.shutil.rmtree, Path.exists + def transition(action, identifier, *args): + if action == 'rollback': + self.fail('A durable commit must never be rolled back after bookkeeping failure') + if action == 'commit': + durable['state'] = 'committed' + if fault == 'command-output': + raise M.Failure('Lost command output after commit') + return {'state':durable['state']} + def write(value): + if fault == 'status-write' and value['state'] == 'committed' and not durable['injected']: + durable['injected'] = True + raise OSError('Simulated status sync failure') + return original_write(value) + def delete(path, *args, **kwargs): + if fault == 'payload-delete' and path == M.PAYLOADS/record['id'] and not durable['injected']: + durable['injected'] = True + raise OSError('Simulated payload cleanup failure') + return original_delete(path, *args, **kwargs) + def exists(path): + return str(path) == f'/run/user/{self.account.pw_uid}/bus' or original_exists(path) + with self.subTest(fault=fault), patch.object(M, 'transaction', side_effect=transition), patch.object(M, 'run', side_effect=self.command), patch.object(Path, 'exists', exists), patch.object(M, 'write', side_effect=write), patch.object(M.shutil, 'rmtree', side_effect=delete): + if fault != 'command-output': + with self.assertRaises(OSError): + M.finalize(record['id']) + self.assertEqual(durable['state'], 'committed') + if fault == 'payload-delete': + self.assertTrue(M.status()['cleanupPending']) + result, code = M.finalize(record['id']) + self.assertEqual(code, 0) + self.assertEqual(result['state'], 'committed') + self.assertNotIn('cleanupPending', M.status()) + self.assertFalse((M.PAYLOADS/record['id']).exists()) + + def test_backup_verification_checks_bytes_and_required_archive_contents(self): + archive = self.root/'backup.tar' + scopes = ['/home/fixture','/etc','/var/lib/xps-hardware','/etc/pki/akmods'] + content = self.root/'backup-content' + for scope in scopes: + directory = content/scope.lstrip('/') + directory.mkdir(parents=True, exist_ok=True) + (directory/'kept').write_text('data') + with tarfile.open(archive,'w') as stream: + for child in content.iterdir(): + stream.add(child, arcname=child.name) + manifest = self.root/'backup.json' + receipt = {'v':1,'createdAt':datetime.now(timezone.utc).isoformat(), + 'archives':[{'path':archive.name,'sha256':M.digest(archive),'covers':scopes}]} + manifest.write_text(json.dumps(receipt)) + actual_run=M.run + def command(values, **kwargs): + if values[0]=='findmnt': + return 'root-device' if values[-1]=='/' else 'backup-device' + return actual_run(values,**kwargs) + with patch.object(M,'run',side_effect=command): + checked=M.verify_backup(manifest,SimpleNamespace(pw_dir='/home/fixture')) + self.assertEqual(checked['sha256'],M.digest(manifest)) + archive.write_bytes(b'corrupted') + with self.assertRaisesRegex(M.Failure,'checksum'): + M.verify_backup(manifest,SimpleNamespace(pw_dir='/home/fixture')) + + def test_timeout_stops_descendants_before_returning(self): + pidfile=self.root/'child.pid' + command=['/bin/sh','-c', 'sh -c \'trap "" TERM; exec sleep 60\' >/dev/null 2>&1 & echo $! > "$1"; wait', 'test', str(pidfile)] + with self.assertRaises(subprocess.TimeoutExpired): + M.run(command,timeout=0.1) + child=int(pidfile.read_text()) + for _ in range(30): + try: + fields=Path(f'/proc/{child}/stat').read_text().split() + if fields[2]=='Z': + break + except FileNotFoundError: + break + time.sleep(0.01) + else: + self.fail('A command descendant survived timeout cleanup') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/native-input-region.py b/tests/native-input-region.py new file mode 100755 index 00000000..b188b2f7 --- /dev/null +++ b/tests/native-input-region.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Input persistence and native region authorization with no host mutations.""" +import copy +import importlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / 'roles/desktop/files/quickshell/scripts' +sys.path.insert(0, str(SCRIPTS)) +inputs = importlib.import_module('system_settings_input') +region = importlib.import_module('system_settings_region') +CATALOG = [{'value': 'us', 'label': 'English', 'variants': [{'value': '', 'label': 'Default'}, {'value': 'intl', 'label': 'International'}]}, + {'value': 'nl', 'label': 'Dutch', 'variants': [{'value': '', 'label': 'Default'}]}] + + +class InputPersistence(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='cybexos-input-test-') + self.addCleanup(self.temp.cleanup) + self.service = inputs.InputSettings() + self.service.path = Path(self.temp.name) / 'config/input.json' + self.service.path.parent.mkdir() + self.run = patch.object(inputs, 'run', return_value='ok').start() + self.addCleanup(patch.stopall) + patch.object(inputs, 'catalog', return_value=CATALOG).start() + + def apply(self, section='touchpad', values=None, version=None): + return self.service.dispatch({'action': 'apply', 'section': section, + 'values': {'tap': False} if values is None else values, + 'version': self.service.read()[1] if version is None else version}) + + def test_unknown_values_and_layout_metadata_survive(self): + original = {'v': 1, 'future': {'unknown': [1, 'data']}, + 'keyboard': {'other': True, 'layouts': [{'layout': 'us', 'variant': '', 'future': 7}]}, + 'touchpad': {'future': 'keep', 'naturalScroll': False}} + self.service.path.write_text(json.dumps(original)) + self.apply() + saved = self.service.read()[0] + expected = copy.deepcopy(original) + expected['touchpad']['tap'] = False + self.assertEqual(saved, expected) + self.apply('keyboard', {'layouts': [{'layout': 'us', 'variant': ''}, {'layout': 'nl', 'variant': ''}]}) + self.assertEqual(self.service.read()[0]['keyboard']['layouts'][0]['future'], 7) + self.assertEqual(self.service.path.stat().st_mode & 0o777, 0o600) + + def test_stale_version_does_not_save_or_reload(self): + version = self.service.read()[1] + self.service.path.write_text('{"v":1,"future":true}') + with self.assertRaisesRegex(ValueError, 'changed elsewhere'): + self.apply(version=version) + self.run.assert_not_called() + self.assertTrue(self.service.read()[0]['future']) + + def test_rejected_reload_restores_original_bytes(self): + old = b'{ "v": 1, "future": 2 }\n' + self.service.path.write_bytes(old) + self.run.side_effect = ['error: bad configuration', 'ok'] + with self.assertRaisesRegex(ValueError, 'restored'): + self.apply() + self.assertEqual(self.service.path.read_bytes(), old) + self.assertEqual(self.run.call_count, 2) + + def test_failed_first_save_removes_created_file(self): + self.run.side_effect = [ValueError('unavailable'), 'ok'] + with self.assertRaisesRegex(ValueError, 'restored'): + self.apply() + self.assertFalse(self.service.path.exists()) + self.assertFalse(list(self.service.path.parent.glob('.input-*'))) + + def test_contained_lua_error_also_restores_original_preferences(self): + old = b'{"v":1,"touchpad":{"tap":true}}\n' + self.service.path.write_bytes(old) + self.run.side_effect = ['ok', 'error: input loader failed', 'ok'] + with self.assertRaisesRegex(ValueError, 'restored'): + self.apply() + self.assertEqual(self.service.path.read_bytes(), old) + + def test_symlink_and_invalid_document_are_never_replaced(self): + destination = self.service.path.parent / 'personal.json' + destination.write_text('{"v":1}') + self.service.path.symlink_to(destination) + with self.assertRaisesRegex(ValueError, 'symlink'): + self.apply() + self.assertEqual(destination.read_text(), '{"v":1}') + self.service.path.unlink() + self.service.path.write_text('{"v":1,"v":1}') + with self.assertRaisesRegex(ValueError, 'invalid'): + self.apply() + self.run.assert_not_called() + + def test_invalid_changes_do_not_write(self): + for section, values in [('touchpad', {'tap': 'yes'}), ('touchpad', {'sensitivity': float('nan')}), + ('touchpad', {'sensitivity': 2}), ('touchpad', {'naturalScroll': 1}), + ('keyboard', {'layouts': []}), ('keyboard', {'layouts': [{'layout': 'bad";code()'}]}), + ('keyboard', {'layouts': [{'layout': 'us', 'variant': 'missing'}]}), + ('keyboard', {'shortcut': 'exec:bad'})]: + with self.subTest(values=values), self.assertRaises(ValueError): + self.apply(section, values) + self.assertFalse(self.service.path.exists()) + self.run.assert_not_called() + + def test_switch_uses_fixed_argument_array_without_saving(self): + self.service.dispatch({'action': 'switch', 'layout': '$(false)'}) + self.run.assert_called_once_with(['hyprctl', 'switchxkblayout', 'all', 'next']) + self.assertFalse(self.service.path.exists()) + + def test_snapshot_accepts_current_and_legacy_hyprland_boolean_responses(self): + # Current Boolean response shape captured from the Fedora 44 live + # desktop. The other documented response fields remain unchanged. + for field, tap, natural in [('bool', False, True), ('int', 0, 1)]: + with self.subTest(field=field): + replies = { + 'input:kb_layout': {'str': 'us,nl', 'set': True}, + 'input:kb_variant': {'str': 'intl,', 'set': True}, + 'input:kb_options': {'str': 'compose:caps,grp:alt_shift_toggle', 'set': True}, + 'input:touchpad:tap_to_click': {field: tap, 'set': True}, + 'input:touchpad:natural_scroll': {field: natural, 'set': True}, + 'input:sensitivity': {'float': 0.0, 'set': False}, + 'devices': {'keyboards': [{'name': 'keyboard', 'active_keymap': 'English (US)', 'main': True}]}, + } + self.run.side_effect = lambda command: json.dumps(replies[command[-1]]) + result = self.service.dispatch({'action': 'snapshot'}) + self.assertEqual(result['touchpad'], {'tap': False, 'naturalScroll': True, 'sensitivity': 0.0}) + self.assertEqual(result['keyboard']['layouts'], [ + {'layout': 'us', 'variant': 'intl'}, {'layout': 'nl', 'variant': ''}]) + self.assertEqual(result['keyboard']['shortcut'], 'grp:alt_shift_toggle') + self.assertEqual(result['keyboard']['active'][0]['layout'], 'English (US)') + self.assertFalse(self.service.path.exists(), 'reading input must not create preferences') + + def test_malformed_boolean_options_do_not_become_enabled_switches(self): + for reply in ({'bool': 'false'}, {'int': '0'}, {'int': 2}, {'int': False}, + {'bool': None, 'int': 0}, {}, []): + with self.subTest(reply=reply), self.assertRaisesRegex(ValueError, 'input configuration'): + self.run.return_value = json.dumps(reply) + self.service.option('touchpad:tap_to_click', 'bool') + + def test_catalog_reads_variants_from_xkb_data(self): + path = Path(self.temp.name) / 'evdev.xml' + path.write_text('usEnglish' + 'intlInternational' + '') + # This test intentionally bypasses the action tests' catalog mock. + patch.stopall() + result = inputs.catalog(path) + self.assertEqual(result[0]['variants'][1], {'value': 'intl', 'label': 'International'}) + + def test_real_stdin_protocol_handles_subprocess_failure(self): + binary = Path(self.temp.name) / 'hyprctl' + binary.write_text('#!/bin/sh\nexit 7\n') + binary.chmod(0o755) + result = subprocess.run([sys.executable, '-B', str(SCRIPTS / 'system-settings.py'), 'input'], + input='{"action":"switch"}\n', capture_output=True, text=True, + env={**os.environ, 'PATH': str(binary.parent), 'XDG_CONFIG_HOME': self.temp.name}, timeout=10) + self.assertEqual(result.returncode, 0) + self.assertFalse(json.loads(result.stdout)['success']) + self.assertEqual(result.stderr, '') + + +class RegionTransactions(unittest.TestCase): + def setUp(self): + self.service = object.__new__(region.RegionSettings) + self.service.call = Mock() + self.timezone = 'Europe/Amsterdam' + self.locale = ['LANG=en_US.UTF-8', 'LC_TIME=nl_NL.UTF-8', 'LC_NUMERIC=C'] + self.service.properties = Mock(side_effect=lambda service: {'Timezone': self.timezone} if service == region.TIME else {'Locale': self.locale.copy()}) + patch.object(region, 'choices', side_effect=lambda command: ['Europe/Amsterdam', 'UTC'] if command[0] == 'timedatectl' else ['en_US.UTF-8', 'nl_NL.UTF-8']).start() + self.addCleanup(patch.stopall) + + def test_locale_keeps_explicit_categories_and_requests_authorization(self): + previous = self.locale.copy() + def apply(*args, **kwargs): + self.locale = args[4][0] + self.service.call.side_effect = apply + result = self.service.dispatch({'action': 'locale', 'value': 'nl_NL.UTF-8', 'previous': previous}) + self.assertIn('LC_TIME=nl_NL.UTF-8', self.locale) + self.assertIn('LC_NUMERIC=C', self.locale) + self.assertIn('LANG=nl_NL.UTF-8', self.locale) + self.assertNotIn('LANG=en_US.UTF-8', self.locale) + self.assertEqual(self.service.call.call_args.args[3], '(asb)') + self.assertTrue(self.service.call.call_args.args[4][1]) + self.assertTrue(self.service.call.call_args.kwargs['interactive']) + self.assertIn('Sign out', result['message']) + + def test_timezone_verifies_after_authorized_write(self): + self.service.call.side_effect = lambda *args, **kwargs: setattr(self, 'timezone', args[4][0]) + self.service.dispatch({'action': 'timezone', 'value': 'UTC', 'previous': self.timezone}) + self.assertEqual(self.timezone, 'UTC') + self.assertEqual(self.service.call.call_args.args[3:], ('(sb)', ('UTC', True))) + + def test_denied_write_reports_failure_without_success(self): + self.service.call.side_effect = ValueError('Authorization was cancelled or denied') + with self.assertRaisesRegex(ValueError, 'denied'): + self.service.dispatch({'action': 'timezone', 'value': 'UTC', 'previous': self.timezone}) + self.assertEqual(self.timezone, 'Europe/Amsterdam') + + def test_stale_and_uninstalled_choices_never_mutate(self): + for request in [{'action': 'timezone', 'value': 'UTC', 'previous': 'stale'}, + {'action': 'locale', 'value': 'nl_NL.UTF-8', 'previous': ['LANG=changed']}, + {'action': 'locale', 'value': 'missing', 'previous': self.locale}, + {'action': 'timezone', 'value': '../etc/passwd', 'previous': self.timezone}]: + with self.subTest(request=request), self.assertRaises(ValueError): + self.service.dispatch(request) + self.service.call.assert_not_called() + + def test_noop_service_is_not_reported_as_success(self): + with self.assertRaisesRegex(ValueError, 'not retained'): + self.service.dispatch({'action': 'timezone', 'value': 'UTC', 'previous': self.timezone}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/qml-lifecycle/shell.qml b/tests/qml-lifecycle/shell.qml index a07239b8..cc7bd08f 100644 --- a/tests/qml-lifecycle/shell.qml +++ b/tests/qml-lifecycle/shell.qml @@ -57,6 +57,63 @@ ShellRoot { root.action.destroy(); root.status.destroy(); } + runCommandLifecycle(); + } + + property int commandStage: 0 + property int commandCompletions: 0 + property bool commandsFinished: false + property Common.CommandRequest request: null + + function runCommandLifecycle() { + request = requestComponent.createObject(harness); + check(request !== null, "CommandRequest did not construct"); + if (!request) { finishLifecycle(); return; } + request.available.connect(() => Qt.callLater(root.nextCommand)); + request.completed.connect((code, body, error) => { + commandCompletions++; + if (commandStage === 0) { + check(code === 0 && body === "ready", "successful command lost stdout"); + } else if (commandStage === 1) { + check(code === -1, "missing executable did not settle as launch failure"); + } else if (commandStage === 2) { + check(code === 124 && body === "", "timeout published partial output or success"); + } else if (commandStage === 3) { + check(code === 0 && body === "after-timeout", "request slot did not recover after timeout"); + } else if (commandStage === 4) { + check(code === 0 && body === "payload", "stdin request was not delivered"); + } + commandStage++; + Qt.callLater(root.nextCommand); + }); + nextCommand(); + } + + function nextCommand() { + if (commandsFinished || request.running) + return; + if (commandStage >= 5) { + commandsFinished = true; + check(commandCompletions === 5, "a command completed more than once"); + request.destroy(); + finishLifecycle(); + return; + } + request.timeoutMs = commandStage === 2 ? 80 : 1000; + request.killGraceMs = 80; + request.stdinEnabled = commandStage === 4; + request.inputText = "payload\n"; + request.command = commandStage === 0 ? ["/usr/bin/printf", "ready"] + : commandStage === 1 ? ["/cybexos-test-executable-does-not-exist"] + : commandStage === 2 ? ["/bin/sh", "-c", "trap '' TERM; printf partial; exec sleep 10"] + : commandStage === 3 ? ["/usr/bin/printf", "after-timeout"] + : ["/bin/sh", "-c", "IFS= read -r value; printf '%s' \"$value\""]; + request.running = true; + } + + Component { id: requestComponent; Common.CommandRequest {} } + + function finishLifecycle() { // Warnings are mirrored to stderr by qs even without detailed-log // decoding, which makes the result observable to the shell driver. console.warn(root.failed ? "LIFECYCLE_RESULT fail" : "LIFECYCLE_RESULT pass"); diff --git a/tests/qml/tst_input_draft.qml b/tests/qml/tst_input_draft.qml new file mode 100644 index 00000000..f6b78158 --- /dev/null +++ b/tests/qml/tst_input_draft.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtTest +import "../../roles/desktop/files/quickshell/Settings/InputDraft.js" as Draft + +TestCase { + name: "NativeInputDraft" + function test_edit_is_isolated_and_only_changed_fields_are_sent() { + const original = {layouts: [{layout: "us", variant: "intl"}], shortcut: "custom:future"}; + const current = Draft.clone(original); + current.layouts = Draft.changeLayout(current.layouts, 0, "nl", ""); + compare(original.layouts[0].layout, "us"); + const patch = Draft.patch(current, original, ["layouts", "shortcut"]); + verify(!("shortcut" in patch)); + compare(patch.layouts[0].layout, "nl"); + compare(patch.layouts[0].variant, ""); + patch.layouts[0].layout = "de"; + compare(current.layouts[0].layout, "nl"); + } + function test_reordering_and_boundaries() { + const layouts = [{layout: "us"}, {layout: "nl"}]; + compare(Draft.move(layouts, 1, -1)[0].layout, "nl"); + compare(layouts[0].layout, "us"); + compare(Draft.move(layouts, 0, -1)[0].layout, "us"); + compare(Draft.move(layouts, 1, 1)[1].layout, "nl"); + } + function test_filter_preserves_selected_option_and_matches_codes() { + const choices = [{value: "us", label: "English"}, {value: "nl", label: "Dutch"}, {value: "de", label: "German"}]; + const result = Draft.filtered(choices, " NL ", "us"); + compare(result.length, 2); + compare(result[0].value, "us"); + compare(result[1].value, "nl"); + compare(Draft.filtered(choices, "german", "")[0].value, "de"); + compare(Draft.filtered(choices, "german", "custom")[0].value, "custom"); + compare(choices.length, 3); + } + function test_touchpad_changes_preserve_false_and_zero() { + const original = {tap: true, naturalScroll: true, sensitivity: 0.5}; + const result = Draft.patch({tap: false, naturalScroll: true, sensitivity: 0}, original, Object.keys(original)); + compare(result.tap, false); + compare(result.sensitivity, 0); + verify(!("naturalScroll" in result)); + } +} diff --git a/tests/qml/tst_settings_document.qml b/tests/qml/tst_settings_document.qml new file mode 100644 index 00000000..64ca2727 --- /dev/null +++ b/tests/qml/tst_settings_document.qml @@ -0,0 +1,149 @@ +import QtQuick +import QtTest +import "../../roles/desktop/files/quickshell/Common/Persistence" as Common +import "../../roles/desktop/files/quickshell/Common/SettingsHelpers.js" as Helpers + +Item { + Component { id: factory; Common.SettingsDocument {} } + Timer { + id: asynchronousCommit + property var callback: null + interval: 5 + onTriggered: if (callback) callback() + } + + TestCase { + name: "SettingsDocumentLifecycle" + property var document: null + + function init() { + document = factory.createObject(parent, { values: Helpers.defaults() }); + verify(document !== null); + } + + function cleanup() { + asynchronousCommit.stop(); + asynchronousCommit.callback = null; + document.destroy(); + } + + function test_default_choice_is_explicit_and_reset_releases_it() { + compare(JSON.parse(document.text()), { v: Helpers.VERSION }); + document.explicitKeys = ["barHeight"]; + compare(JSON.parse(document.text()).barHeight, Helpers.defaults().barHeight); + document.explicitKeys = []; + verify(!("barHeight" in JSON.parse(document.text()))); + } + + function test_unknown_fields_survive_a_real_qml_save_cycle() { + document.source = { v: 26, future: { opaque: [1, "keep"] }, + modOpts: { weather: { place: "Home", futureOption: true } } }; + document.explicitKeys = ["modOpts"]; + document.values = Helpers.merge(document.source); + verify(document.begin()); + const saved = document.submitted; + compare(JSON.parse(saved).future.opaque, [1, "keep"]); + verify(JSON.parse(saved).modOpts.weather.futureOption); + const complete = document.complete(saved); + verify(!complete.pending); + verify(!document.busy); + compare(document.baseline, saved); + } + + function test_edit_during_write_rebases_on_external_changes() { + document.source = { v: Helpers.VERSION, barHeight: 40 }; + document.explicitKeys = ["barHeight"]; + document.values = Helpers.merge(document.source); + document.baseline = JSON.stringify(document.source); + verify(document.begin()); + verify(!document.begin(), "a second writer must not overlap"); + const next = Helpers.clone(document.values); + next.unit = "f"; + document.values = next; + document.explicitKeys = ["barHeight", "unit"]; + // The atomic writer merged an independent edit from another process. + const saved = JSON.stringify({ v: Helpers.VERSION, barHeight: 40, + themeMode: "light", future: "external" }); + const result = document.complete(saved); + verify(result.pending); + compare(result.values.unit, "f"); + compare(result.values.themeMode, "light"); + document.values = result.values; + const second = JSON.parse(document.text()); + compare(second.unit, "f"); + compare(second.future, "external"); + verify(document.begin()); + verify(!document.complete(document.submitted).pending); + } + + function test_failed_write_keeps_pending_values_for_retry() { + document.explicitKeys = ["themeMode"]; + const next = Helpers.clone(document.values); + next.themeMode = "light"; + document.values = next; + verify(document.begin()); + const candidate = document.submitted; + document.abandon(); + verify(!document.busy); + verify(document.begin()); + compare(document.submitted, candidate); + } + + function test_reset_retains_unknown_nested_fields() { + document.source = { v: Helpers.VERSION, + modOpts: { weather: { place: "Chosen", futureOption: "keep" } }, + futureRoot: [1, 2] }; + document.values = Helpers.merge(document.source); + document.explicitKeys = []; + const reset = JSON.parse(document.text()); + compare(reset.modOpts.weather, { futureOption: "keep" }); + compare(reset.futureRoot, [1, 2]); + compare(Helpers.overrideKeys(reset), []); + } + + function test_event_loop_edit_survives_delayed_completion_and_reload() { + document.source = { v: Helpers.VERSION, unit: "c" }; + document.explicitKeys = ["unit"]; + document.values = Helpers.merge(document.source); + verify(document.begin()); + const first = document.submitted; + let completed = false; + asynchronousCommit.callback = function() { + const result = document.complete(first); + verify(result.pending); + compare(result.values.unit, "f"); + document.values = result.values; + completed = true; + }; + asynchronousCommit.start(); + Qt.callLater(function() { + const edited = Helpers.clone(document.values); + edited.unit = "f"; + document.values = edited; + }); + tryVerify(() => completed); + verify(document.begin()); + const second = document.complete(document.submitted); + verify(!second.pending); + compare(Helpers.merge(JSON.parse(document.baseline)).unit, "f"); + } + + function test_concurrent_same_key_is_rejected() { + let failed = false; + try { + Helpers.rebaseDocuments({ unit: "c" }, { unit: "f" }, { unit: "other" }); + } catch (_) { failed = true; } + verify(failed); + } + + function test_legacy_default_equality_never_resets_a_choice() { + const chosen = Helpers.merge({ v: 3, font: "oppo", barHeight: 30, + barRadius: 9, gap: 8, accent: "#9ecbeb" }); + compare(chosen.font, "oppo"); + compare(chosen.barHeight, 30); + compare(chosen.gap, 8); + compare(chosen.accent, "#9ecbeb"); + compare(Helpers.merge({ v: 6, font: "urbanist" }).font, "urbanist"); + } + } +} diff --git a/tests/quickshell/deployment-convergence.test.cjs b/tests/quickshell/deployment-convergence.test.cjs index f6f7b97d..e49ee65a 100644 --- a/tests/quickshell/deployment-convergence.test.cjs +++ b/tests/quickshell/deployment-convergence.test.cjs @@ -80,7 +80,7 @@ test("Podman inventory controls packages, helpers, keybindings, and desktop entr assert.match(desktopTasks.slice(featureInstallAt, luaConsumersAt), /- features\.lua\s+- monitors\.lua\s+- input\.lua\s+- looknfeel\.lua/, "all imported leaf modules must precede the activating entrypoint"); - assert.match(hyprland, /\{ "features", "monitors", "input", "bindings"/, + assert.match(hyprland, /\{ "features", "monitors", "input", "input_preferences", "bindings"/, "a config reload must evict the rendered feature module before reloading bindings"); assert.match(bindings, /local features = require\("features"\)/); const optionalBinds = bindings.slice(bindings.indexOf("if features.podman then"), diff --git a/tests/quickshell/display-settings.test.cjs b/tests/quickshell/display-settings.test.cjs index b02b3285..664236be 100644 --- a/tests/quickshell/display-settings.test.cjs +++ b/tests/quickshell/display-settings.test.cjs @@ -40,7 +40,8 @@ test("Hyprland loads saved displays after vendor monitors and before user.lua, c assert.ok(install.indexOf("- autostart.lua") < install.indexOf("- displays.lua") && install.indexOf("- displays.lua") < install.indexOf("- hyprland.lua"), "the module lands before the entrypoint that requires it"); - assert.match(tasks, /'displays\.lua', 'hyprland\.lua'/, "stale-entry pruning keeps the module"); + const prune = tasks.split('\n').find(line => line.includes('item.path | basename not in')); + assert.ok(prune && prune.includes("'displays.lua'"), "stale-entry pruning keeps the module"); assert.match(fs.readFileSync(path.join(repo, "image/package"), "utf8"), /"autostart\.lua", "displays\.lua"/); }); diff --git a/tests/quickshell/fileview-writes.test.cjs b/tests/quickshell/fileview-writes.test.cjs index f96e3636..d743f91f 100644 --- a/tests/quickshell/fileview-writes.test.cjs +++ b/tests/quickshell/fileview-writes.test.cjs @@ -1,4 +1,4 @@ -// Settings and Notes persist through Quickshell's FileView, whose write path +// Notes persist through Quickshell's FileView, whose write path // has two properties that are easy to wedge on: setText() compares against // the last bytes the view read *or tried to write* and silently skips a // match, and a failed write still leaves its bytes in that cache. A save @@ -13,7 +13,6 @@ const path = require("node:path"); const vm = require("node:vm"); const { shellDir, load } = require("./shell.cjs"); -const SettingsHelpers = load("SettingsHelpers.js"); const NotesHelpers = load("NotesHelpers.js"); const FileViewError = { Success: 0, Unknown: 1, FileNotFound: 2, toString: String }; @@ -123,184 +122,10 @@ function timer(context, trigger) { }; } -function settingsHarness(disk, blockWrites) { - const source = read("Common/Settings.qml"); - const store = fileView(disk, blockWrites); - const context = { - SettingsHelpers, FileViewError, Quickshell: { env: () => "" }, - console: { warn() {} }, - filePath: "/home/test/.config/cybexos/shell.json", - loaded: false, ready: false, firstRun: false, migrationPending: false, - loadError: false, loadErrorText: "", newerSchema: false, recheckPending: false, - initialLoadHandled: false, lastPersistedText: "", storeText: "", - savePending: false, saveError: false, announcement: "", revision: 0, - corruptBackupPending: false, writeInFlight: false, writeSnapshot: "", - reloadAfterWrite: false, lastSavedAt: 0, applied: 0, - store, - clearUndo() {}, backUpCorruptFile() {}, applyScrollFactor() {}, - applyGlassEffect() { context.applied++; } - }; - context.root = context; - context.defaults = SettingsHelpers.defaults(); - Object.assign(context, SettingsHelpers.defaults()); - context.saveTimer = timer(context, c => c.saveNow()); - context.reloadTimer = timer(context, c => c.reloadStore ? c.reloadStore() : c.store.reload()); - vm.createContext(context); - for (const name of ["snapshot", "seedWeatherFromEnv", "protectNewerFile", "assignChanged", - "applyLoaded", "handleLoadFailure", "sameContent", "handleSaveSucceeded", - "handleSaveFailure", "saveNow", "scheduleSave", "retrySave", "set", "reloadStore", - "releaseWriteGuard"] - .filter(name => hasFunction(source, name))) - vm.runInContext(functionSource(source, name), context); - store.on = { - loaded: text => context.applyLoaded(text), - loadFailed: error => context.handleLoadFailure(error), - saved: () => context.handleSaveSucceeded(), - saveFailed: error => context.handleSaveFailure(error) - }; - if (disk === null) - context.handleLoadFailure(FileViewError.FileNotFound); - else - context.applyLoaded(disk); - // A property write runs its onChanged handler, which schedules a save. - context.change = (key, value) => { - context.set(key, value); - context.scheduleSave(); - }; - // Lets the debounced save run and the event loop deliver what it owes. - context.flush = () => { - context.saveTimer.fire(); - store.settle(); - }; - return context; -} - -function settingsText(changes) { - return SettingsHelpers.serialize(Object.assign(SettingsHelpers.defaults(), changes || {})); -} - -for (const blockWrites of [true, false]) { - const mode = blockWrites ? "blocking" : "async"; - - test(`settings: Retry after a failed save writes the same content (${mode})`, () => { - const settings = settingsHarness(settingsText({ barHeight: 40 }), blockWrites); - const store = settings.store; - - store.failWrites = 1; - settings.change("barHeight", 44); - settings.flush(); - assert.equal(settings.saveError, true); - assert.equal(settings.writeInFlight, false); - assert.equal(JSON.parse(store.disk).barHeight, 40, "the failed write left the file alone"); - - // Nothing changed since: FileView still holds the failed attempt and - // would skip the identical bytes without a signal. - settings.retrySave(); - store.settle(); - assert.equal(store.skipped, 0, "Retry must never hand FileView the bytes it would skip"); - assert.equal(settings.writeInFlight, false, "the write guard must not outlive the save"); - assert.equal(settings.saveError, false); - assert.equal(settings.savePending, false, - "a newline-only difference is not a change made while saving"); - assert.equal(JSON.parse(store.disk).barHeight, 44); - - // Its own echo, extra newline and all, is not an external edit. - const applied = settings.applied; - store.reload(); - store.settle(); - assert.equal(settings.applied, applied); - - // And the session keeps saving afterwards. - settings.change("barHeight", 48); - settings.flush(); - assert.equal(settings.writeInFlight, false); - assert.equal(settings.savePending, false); - assert.equal(store.disk, settingsText({ barHeight: 48 })); - }); - - test(`settings: repeated failures alternate until a write lands (${mode})`, () => { - const settings = settingsHarness(settingsText(), blockWrites); - const store = settings.store; - - store.failWrites = 3; - settings.change("gap", 6); - settings.flush(); - for (let attempt = 0; attempt < 2; attempt++) { - settings.retrySave(); - store.settle(); - assert.equal(settings.saveError, true); - assert.equal(settings.writeInFlight, false); - } - settings.retrySave(); - store.settle(); - assert.equal(store.skipped, 0); - assert.equal(settings.saveError, false); - assert.equal(JSON.parse(store.disk).gap, 6); - }); - - test(`settings: a failed change undone before Retry settles without writing (${mode})`, () => { - const initial = settingsText({ barHeight: 40 }); - const settings = settingsHarness(initial, blockWrites); - const store = settings.store; - - store.failWrites = 1; - settings.change("barHeight", 44); - settings.flush(); - assert.equal(settings.saveError, true); - settings.change("barHeight", 40); - settings.flush(); - assert.equal(settings.saveError, false, "the file already holds these settings"); - assert.equal(settings.writeInFlight, false); - assert.equal(settings.savePending, false); - assert.equal(store.writes, 0); - assert.equal(store.disk, initial); - }); - - test(`settings: first run failure and retry reach the missing file (${mode})`, () => { - const settings = settingsHarness(null, blockWrites); - const store = settings.store; - assert.equal(settings.firstRun, true); - - store.failWrites = 1; - settings.change("barHeight", 44); - settings.flush(); - assert.equal(settings.saveError, true); - assert.equal(store.disk, null); - settings.retrySave(); - store.settle(); - assert.equal(settings.saveError, false); - assert.equal(settings.writeInFlight, false); - assert.equal(JSON.parse(store.disk).barHeight, 44); - }); -} - -test("settings: a reload that comes due under an async write runs after it", () => { - const settings = settingsHarness(settingsText({ barHeight: 40 }), false); - const store = settings.store; - - settings.change("barHeight", 44); - settings.saveTimer.fire(); - assert.equal(settings.writeInFlight, true); - // The watcher reports a change while our write is still in flight. - settings.reloadTimer.restart(); - settings.reloadTimer.fire(); - // A change made meanwhile waits for the write instead of joining it. - settings.change("gap", 6); - settings.saveTimer.fire(); - store.settle(); - assert.equal(store.swallowedReloads, 0, "FileView drops a reload issued under its write"); - assert.equal(settings.reloadTimer.running, true, "the deferred reload is re-armed"); - assert.equal(settings.saveTimer.running, true, "the change made while saving is queued"); - - // Someone edits the file after our write landed. - store.disk = settingsText({ barHeight: 44, gap: 6, clock24: false }); - settings.reloadTimer.fire(); - store.settle(); - assert.equal(settings.clock24, false, "the external edit must still be read"); - settings.flush(); - assert.equal(settings.writeInFlight, false); - assert.equal(settings.savePending, false); -}); +// Shell settings now use the atomic settings-store transaction helper rather +// than FileView.setText. Their production Qt queue is exercised directly by +// tests/qml/tst_settings_document.qml; tests/settings-ownership.py verifies +// concurrent filesystem edits, migration backup, failure and future schemas. function notesHarness(disk, blockWrites) { const source = read("Common/Notes.qml"); @@ -494,7 +319,7 @@ test("the persistence comments describe what FileView actually reports", () => { const settings = read("Common/Settings.qml"); assert.doesNotMatch(settings, /a failed atomic rename is still a failed save/, "Quickshell logs a failed atomic commit and still emits saved"); - for (const file of ["Common/Settings.qml", "Common/Notes.qml"]) { + for (const file of ["Common/Notes.qml"]) { const source = read(file); assert.match(functionSource(source, "handleSaveFailure"), /storeText = writeSnapshot;/, `${file} must remember that FileView kept the failed attempt`); diff --git a/tests/quickshell/github-inbox-structure.test.cjs b/tests/quickshell/github-inbox-structure.test.cjs index 7ae54b13..01c87baf 100644 --- a/tests/quickshell/github-inbox-structure.test.cjs +++ b/tests/quickshell/github-inbox-structure.test.cjs @@ -96,34 +96,21 @@ test("Inbox reads use conditional HTTP polling and preserve partial caches", () assert.match(active, /rateLimited\(\)/); }); -test("a stalled gh read is bounded by a watchdog that releases the queue", () => { +test("gh requests use the bounded transport and keep interactive deadlines", () => { const source = read("Common/GitHub.qml"); - const pump = source.match(/function pump\(\)[\s\S]*?\n \}/)?.[0] ?? ""; - assert.match(pump, - /ghWatchdog\.interval = Helpers\.ghTimeoutMs\(job\);\s*ghWatchdog\.restart\(\);\s*ghProc\.running = true;/, - "the watchdog is armed before launch so a synchronous failed start disarms it"); - const fired = source.match(/function ghWatchdogFired\(\)[\s\S]*?\n \}/)?.[0] ?? ""; - assert.match(fired, /ghProc\.timedOut = true[\s\S]*ghProc\.running = false/, - "first firing terminates through the normal falling edge"); - assert.match(fired, /ghProc\.signal\(9\)[\s\S]*ghProc\.abandoned = true;\s*settle\(Helpers\.GH_TIMEOUT_EXIT/, - "a process that ignores SIGTERM is killed and its job settled directly"); - const proc = source.match(/Process \{\s*id: ghProc[\s\S]*?\n \}/)?.[0] ?? ""; - assert.match(proc, /ghWatchdog\.stop\(\)/); - assert.match(proc, /if \(abandoned\) \{[\s\S]*root\.pump\(\);\s*return;/, - "an abandoned job must not settle twice"); - assert.match(proc, /if \(timedOut\)\s*root\.settle\(Helpers\.GH_TIMEOUT_EXIT, "", timeoutText\)/); - assert.match(proc, /ProcHelpers\.NOT_STARTED/, - "a never-started gh still settles through the falling edge"); - assert.match(source, /Timer \{\s*id: ghWatchdog[\s\S]*?onTriggered: root\.ghWatchdogFired\(\)/); + assert.match(source, /ghProc\.timeoutMs = Helpers\.ghTimeoutMs\(job\)/); + assert.match(source, /ghProc\.timeoutMessage = Helpers\.ghTimeoutMessage\(job\)/); + assert.match(source, /CommandRequest \{\s*id: ghProc/); + assert.match(source, /onCompleted: \(code, body, error\) => root\.settle\(code, body, error\)/); + assert.match(source, /onAvailable: root\.pump\(\)/); const helpers = load("GitHubHelpers.js"); assert.equal(helpers.globalInboxFailure(helpers.GH_TIMEOUT_EXIT, - helpers.ghTimeoutMessage({ interactive: false })), true, - "a timed-out Inbox read pauses the sweep with backoff"); + helpers.ghTimeoutMessage({ interactive: false })), true); }); test("interactive reads outrank polling and stale Inbox scopes are rejected", () => { const source = read("Common/GitHub.qml"); - assert.match(source, /firstBackground = queue\.findIndex\(queued => !queued\.interactive\)/); + assert.match(source, /Queue\.enqueue\(queue, active, job\)/); assert.match(source, /kind: "commits"[\s\S]{0,180}?interactive: true/); assert.match(source, /kind: "stats"[\s\S]{0,100}?interactive: true/); assert.match(source, /job\.generation !== scopeGeneration \|\| inboxSweep === null/); diff --git a/tests/quickshell/github-queue.test.cjs b/tests/quickshell/github-queue.test.cjs new file mode 100644 index 00000000..dcac636b --- /dev/null +++ b/tests/quickshell/github-queue.test.cjs @@ -0,0 +1,28 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { load } = require("./shell.cjs"); +const Q = load("GitHubQueue.js"); + +test("interactive gh reads preserve FIFO priority and promote richer queued jobs", () => { + let queue = []; + const add = job => { const result = Q.enqueue(queue, null, job); queue = result.queue; return result.added; }; + add({ kind: "repos" }); + add({ kind: "commits", slug: "a/b", toast: true, since: "yesterday" }); + add({ kind: "stats", sha: "first", interactive: true }); + assert.equal(add({ kind: "commits", slug: "a/b", interactive: true }), false); + assert.deepEqual(queue.map(Q.jobKey), ["stats:first", "commits:a/b", "repos"]); + assert.equal(queue[1].toast, true); + assert.equal(queue[1].since, "yesterday"); + assert.equal(queue[1].interactive, true); +}); + +test("deduplication separates stale generations and never duplicates an active request", () => { + const active = { kind: "events", slug: "a/b", generation: 1 }; + const original = []; + assert.strictEqual(Q.enqueue(original, active, { ...active, interactive: true }).queue, original); + const result = Q.enqueue(original, active, { ...active, generation: 2 }); + assert.equal(result.added, true); + assert.equal(result.queue.length, 1); + assert.equal(result.queue[0].generation, 2); + assert.equal(original.length, 0, "queue policy must not mutate prior published state"); +}); diff --git a/tests/quickshell/hermes-ui.test.cjs b/tests/quickshell/hermes-ui.test.cjs index 93db402c..549082b8 100644 --- a/tests/quickshell/hermes-ui.test.cjs +++ b/tests/quickshell/hermes-ui.test.cjs @@ -533,7 +533,9 @@ test("Hermes exposes capability-gated attachments, branches, editing, and regene const inbox = read("Popovers/HermesInboxPage.qml"); const helpers = read("Common/HermesHelpers.js"); const bridge = readRepo( - "roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py"); + "roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py") + + readRepo("roles/desktop/files/hermes-menubar-bridge/cybex_hermes/auth.py") + + readRepo("roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py"); assert.match(composer, /Hermes\.capabilities\.attachments === true/); assert.match(facade, /"zenity", "--file-selection", "--multiple"/); diff --git a/tests/quickshell/notes-widget.test.cjs b/tests/quickshell/notes-widget.test.cjs index 52f14b36..9c88cd9f 100644 --- a/tests/quickshell/notes-widget.test.cjs +++ b/tests/quickshell/notes-widget.test.cjs @@ -13,7 +13,7 @@ function read(relative) { } test("defaults enable Notes immediately after Weather with opt-in title settings", () => { - assert.equal(Settings.VERSION, 26); + assert.equal(Settings.VERSION, 27); const center = Settings.defaultMods().center; const weather = center.findIndex(entry => entry.id === "weather"); assert.equal(center[weather + 1].id, "notes"); diff --git a/tests/quickshell/settings-helpers.test.cjs b/tests/quickshell/settings-helpers.test.cjs index 9f577ed4..71173d8b 100644 --- a/tests/quickshell/settings-helpers.test.cjs +++ b/tests/quickshell/settings-helpers.test.cjs @@ -6,7 +6,7 @@ const H = load("SettingsHelpers.js"); test("defaults carry the design values", () => { const d = H.defaults(); - assert.equal(H.VERSION, 26); + assert.equal(H.VERSION, 27); assert.deepEqual(d.drawerTabs.map(t => t.id), ["overview", "sound", "network", "bluetooth", "power", "notifications"]); assert.ok(d.drawerTabs.every(t => t.on === true)); @@ -514,7 +514,7 @@ test("merge falls back on invalid enums, colors and names", () => { test("schema-5 bar modes migrate without losing customized geometry", () => { assert.equal(H.merge({ v: 5, floating: true, barHeight: 46, barRadius: 23, gap: 10 - }).barStyle, "hug", "pristine floating geometry adopts the new default"); + }).barStyle, "floating", "an explicit old floating mode remains floating"); assert.equal(H.merge({ v: 5, floating: true, barHeight: 44, barRadius: 23, gap: 10 }).barStyle, "floating", "a customized height remains floating"); @@ -530,7 +530,7 @@ test("schema-5 colors select wallpaper or fixed mode and remain stored", () => { const pristine = H.merge({ v: 5, accent: "#5e9bff", barColorMode: "default" }); - assert.equal(pristine.paletteMode, "wallpaper"); + assert.equal(pristine.paletteMode, "fixed"); const oldWallpaperAccent = H.merge({ v: 5, accentWall: true, accent: "#a992e0", barColorMode: "black" @@ -578,9 +578,9 @@ test("missing font preferences use the current default across first-run and lega "an explicitly saved font remains selectable"); }); -test("schema-7 adopts Google Sans only from the previous default", () => { - assert.equal(H.merge({ v: 6, font: "urbanist" }).font, "google", - "the old untouched default follows the softer typography pass"); +test("legacy fonts remain explicit even when equal to an old default", () => { + assert.equal(H.merge({ v: 6, font: "urbanist" }).font, "urbanist", + "a saved former default is still the user choice"); assert.equal(H.merge({ v: 6, font: "plex" }).font, "plex", "an explicit previous-schema choice survives"); assert.equal(H.merge({ v: H.VERSION, font: "urbanist" }).font, "urbanist", @@ -619,7 +619,7 @@ test("normalizeMods falls back to the default flag for a non-boolean", () => { assert.equal(next.left[0].on, true); }); -test("a schema-3 file adopts the redesign only where it was left untouched", () => { +test("a schema-3 file retains every saved choice across visual redesigns", () => { // The glass menubar changed the bar's proportions, and a settings file // written by the previous schema carries the old ones for every key. A // value the user never moved takes the new default; one they did is theirs. @@ -628,14 +628,14 @@ test("a schema-3 file adopts the redesign only where it was left untouched", () font: "oppo", osd: "top", modOpts: { ws: { style: "numbers" }, media: { maxWidth: 220 } } }); - assert.equal(untouched.barHeight, 36); - assert.equal(untouched.barRadius, 11); + assert.equal(untouched.barHeight, 30); + assert.equal(untouched.barRadius, 9); assert.equal(untouched.gap, 8); - assert.equal(untouched.accent, "#d3d283"); - assert.equal(untouched.font, "mono"); - assert.equal(untouched.osd, "bottom"); + assert.equal(untouched.accent, "#9ecbeb"); + assert.equal(untouched.font, "oppo"); + assert.equal(untouched.osd, "top"); assert.equal(untouched.modOpts.ws.style, "numbers"); - assert.equal(untouched.modOpts.media.maxWidth, 180); + assert.equal(untouched.modOpts.media.maxWidth, 220); const chosen = H.merge({ v: 3, barHeight: 36, accent: "#a992e0", font: "mono", @@ -684,7 +684,7 @@ test("schema-4 appearance choices survive later schema upgrades", () => { previous.barCustomLightness), H.BAR_COLOR_PRESETS.default.light); }); -test("schema-9 adopts the classic bar only from untouched design values", () => { +test("schema-9 retains explicit former defaults across the classic redesign", () => { const untouched = H.merge({ v: 9, glassEnabled: true, @@ -701,18 +701,18 @@ test("schema-9 adopts the classic bar only from untouched design values", () => clock: { dateFormat: "ddd d MMM" } } }); - assert.equal(untouched.glassEnabled, false); - assert.equal(untouched.barHeight, 36); - assert.equal(untouched.barRadius, 11); - assert.equal(untouched.gap, 8); - assert.equal(untouched.accent, "#d3d283"); + assert.equal(untouched.glassEnabled, true); + assert.equal(untouched.barHeight, 46); + assert.equal(untouched.barRadius, 23); + assert.equal(untouched.gap, 10); + assert.equal(untouched.accent, "#5e9bff"); assert.deepEqual([ untouched.barCustomHue, untouched.barCustomSaturation, untouched.barCustomLightness - ], [230, 14, 9]); - assert.equal(untouched.modOpts.ws.style, "numbers"); - assert.equal(untouched.modOpts.clock.dateFormat, "ddd dd"); + ], [247, 29, 11]); + assert.equal(untouched.modOpts.ws.style, "dots"); + assert.equal(untouched.modOpts.clock.dateFormat, "ddd d MMM"); const chosen = H.merge({ v: 9, @@ -961,9 +961,8 @@ test("pre-OCR action lists place OCR beside recording without overriding visibil assert.deepEqual(untouched.order, [ "dictation", "recording", "ocr", "reminder", "night-light", "dnd", "stay-awake" ]); - assert.deepEqual(untouched.enabled, [ - "dictation", "recording", "ocr", "reminder", "night-light", "dnd", "stay-awake" - ]); + assert.deepEqual(untouched.enabled, priorIds, + "a stored enabled list never implies permission to enable a new action"); const customized = H.merge({ v: H.VERSION, diff --git a/tests/quickshell/settings-persistence.test.cjs b/tests/quickshell/settings-persistence.test.cjs index 7383e5f9..3b1559d5 100644 --- a/tests/quickshell/settings-persistence.test.cjs +++ b/tests/quickshell/settings-persistence.test.cjs @@ -56,7 +56,8 @@ function settingsHarness() { loadError: false, loadErrorText: "", newerSchema: false, recheckPending: false, initialLoadHandled: false, lastPersistedText: "", savePending: false, announcement: "", corruptBackupPending: false, writeInFlight: false, - saveError: false, writeSnapshot: "", + saveError: false, writeSnapshot: "", sourceDocument: {}, explicitOverrides: [], + revision: 0, saveTimer: { stop() {}, restart() {} }, reloadTimer: { restart() { calls.rechecks++; } }, store: { setText() { calls.writes++; } }, @@ -73,7 +74,7 @@ function settingsHarness() { Object.assign(context, H.defaults()); vm.createContext(context); for (const name of ["snapshot", "seedWeatherFromEnv", "protectNewerFile", "assignChanged", - "applyLoaded", "handleLoadFailure", "saveNow", "set"]) + "applyLoaded", "handleLoadFailure", "saveNow", "set", "markExplicit", "scheduleSave"]) vm.runInContext(functionSource(source, name), context); return { context, calls }; } @@ -92,6 +93,7 @@ test("our own save echoes back as a no-op, byte for byte", () => { // Writers normalize, so a drifted slider value never reaches memory. context.set("scrollFactor", 7 * 0.1); assert.equal(context.scrollFactor, 0.7); + context.savePending = false; // the previous UI edit completed before this editor reload const external = H.serialize(Object.assign(H.defaults(), { scrollFactor: 1.2 })); context.applyLoaded(external); assert.equal(calls.applied, 2); diff --git a/tests/quickshell/settings-rows.test.cjs b/tests/quickshell/settings-rows.test.cjs index e0ef8263..cbbbcf8b 100644 --- a/tests/quickshell/settings-rows.test.cjs +++ b/tests/quickshell/settings-rows.test.cjs @@ -116,7 +116,7 @@ test("rows that cannot use settingKey still wire themselves completely", () => { // Their enable/remove controls preserve state, rather than inventing a reset. // System-owned values have no shell schema default. Their pages own // live writes or an explicit Apply/Cancel draft, never shell reset. - if (["ModulesPage", "PluginWidgetSettings", "SoundPage", "NetworkPage", "DisplaysPage", "AccountsPage", "IpSettings"] + if (["ModulesPage", "PluginWidgetSettings", "SoundPage", "NetworkPage", "DisplaysPage", "AccountsPage", "IpSettings", "KeyboardPage", "TouchpadPage", "RegionPage"] .some(name => block.at.startsWith(`Settings/${name}.qml:`))) { assert.match(text, /on(?:Toggled|Picked|Moved|Committed):/, `${block.at}: missing write handler`); continue; diff --git a/tests/quickshell/settings-schema.test.cjs b/tests/quickshell/settings-schema.test.cjs index 3b288c75..f86a28b0 100644 --- a/tests/quickshell/settings-schema.test.cjs +++ b/tests/quickshell/settings-schema.test.cjs @@ -70,7 +70,7 @@ test("every schema key has a property that defaults to it", () => { test("saving and loading enumerate the schema rather than restating it", () => { const snapshot = SETTINGS.slice(SETTINGS.indexOf("function snapshot()"), - SETTINGS.indexOf("function seedWeatherFromEnv")); + SETTINGS.indexOf(" SettingsDocument {")); assert.match(snapshot, /for \(const key of Object\.keys\(root\.defaults\)\)/, "snapshot() must loop the schema"); assert.ok(snapshot.split("\n").length < 15, diff --git a/tests/quickshell/settings.test.cjs b/tests/quickshell/settings.test.cjs index 2335dd70..d121e62b 100644 --- a/tests/quickshell/settings.test.cjs +++ b/tests/quickshell/settings.test.cjs @@ -380,7 +380,7 @@ test("regression fixes keep asynchronous state identity-safe", () => { test("schema twenty-three keeps safe defaults and exposes accessibility preferences", () => { const helpers = read("Common/SettingsHelpers.js"); - assert.match(helpers, /var VERSION = 26/); + assert.match(helpers, /var VERSION = 27/); // Schema 17: the drawer becomes configurable (turn-3 settings design). assert.match(helpers, /drawerHover: "open"/); assert.match(helpers, /drawerWidth: 400/); @@ -413,17 +413,15 @@ test("schema twenty-three keeps safe defaults and exposes accessibility preferen assert.match(helpers, /barStyle:\s*"hug"/); assert.match(helpers, /function migrateBarStyle\(parsed, defaultsValue\)/); assert.match(helpers, /function migratePaletteMode\(parsed, defaultsValue\)/); - assert.match(helpers, /function adoptSofterTypography\(parsed\)/); + assert.doesNotMatch(helpers, /function adoptSofterTypography\(parsed\)/); assert.match(helpers, /mod\("media", true\)/); assert.match(helpers, /mod\("bt", true\)/); assert.match(helpers, /wallDir:\s*"~\/Pictures\/Wallpapers"/); assert.match(helpers, /DETAIL_POLICIES/); - // A settings file written by the previous schema must adopt the redesign - // wherever the user never chose otherwise, or the redesign never appears. - assert.match(helpers, /function adoptRedesign\(parsed\)/); - assert.match(helpers, /V3_DEFAULTS = \{[\s\S]*?barHeight: 30/); - assert.match(helpers, /function adoptClassicMenubar\(parsed\)/); - assert.match(helpers, /V9_CLASSIC_DEFAULTS = \{[\s\S]*?barHeight: 46/); + // Stored legacy defaults are indistinguishable from explicit choices. + assert.doesNotMatch(helpers, /function adoptRedesign|function adoptClassicMenubar/); + assert.match(helpers, /function serializeDocument/); + }); test("Connected enables integration widgets including auto-hiding Bluetooth", () => { @@ -562,7 +560,7 @@ test("the grouped rail keeps labeled sections, the save state, and the nav searc const schemaKeys = Object.keys(load("SettingsHelpers.js").defaults()); const validPages = [...settings.matchAll(/\{ id: "([a-z]+)", group: "/g)].map(m => m[1]); assert.deepEqual(validPages, ["appearance", "wallpaper", "bar", "notifications", "displays", - "sound", "network", "touchpad", "power", "region", "accounts", "plugins", "about"]); + "sound", "network", "touchpad", "keyboard", "power", "region", "accounts", "plugins", "about"]); const rows = load("SettingsSearchData.js").ROWS; assert.ok(rows.length >= 30, "the search index must cover the workspace"); for (const row of rows) { @@ -969,53 +967,23 @@ test("FileView failures cannot become empty settings or false save success", () /function handleLoadFailure\(error\)[\s\S]*?ready = false;[\s\S]*?loadError = true;/, "other read errors must retain memory state and disable writes"); assert.doesNotMatch(settings, /onLoadFailed:\s*root\.applyLoaded\(""\)/); - assert.match(settings, /onSaved: root\.handleSaveSucceeded\(\)/); - assert.match(settings, - /onSaveFailed: error => root\.handleSaveFailure\(error\)/); + assert.match(settings, /root\.handleSaveSucceeded\(result\.text\)/); + assert.match(settings, /root\.handleSaveFailure\(FileViewError\.Unknown\)/); + assert.match(settings, /CommandRequest \{\s*id: settingsWriter/); const saveNow = settings.slice(settings.indexOf("function saveNow()"), settings.indexOf("function scheduleSave()")); assert.doesNotMatch(saveNow, /lastSavedAt\s*=/, "starting a write is not evidence that it succeeded"); assert.match(settings, - /function handleSaveSucceeded\(\)[\s\S]*lastSavedAt = Date\.now\(\)/); + /function handleSaveSucceeded\(committed\)[\s\S]*lastSavedAt = Date\.now\(\)/); }); -test("an unchanged save cannot block subsequent widget changes", () => { - const vm = require("node:vm"); +test("settings use the verified atomic writer and production document queue", () => { const source = read("Common/Settings.qml"); - let value = { mods: { left: [{ id: "ws", on: true }] } }; - let disk = JSON.stringify(value); - let writes = 0; - const context = vm.createContext({ - ready: true, migrationPending: false, corruptBackupPending: false, loadError: false, - writeInFlight: false, writeSnapshot: "", lastPersistedText: disk, storeText: disk, - reloadAfterWrite: false, - saveError: false, savePending: true, lastSavedAt: 0, - SettingsHelpers: { serialize: JSON.stringify }, snapshot: () => value, - saveTimer: { restart() {} }, FileViewError: { Unknown: 1 }, - store: { setText(text) { - if (text === disk) return; // FileView emits no saved signal for a no-op. - writes++; disk = text; context.handleSaveSucceeded(); - } } - }); - for (const name of ["sameContent", "releaseWriteGuard", "saveNow", "handleSaveSucceeded", - "handleSaveFailure"]) { - const body = source.match(new RegExp(" function " + name + "\\([^]*?^ }", "m"))[0]; - vm.runInContext(body, context); - } - context.saveNow(); - assert.equal(context.writeInFlight, false); - assert.equal(context.savePending, false); - assert.equal(writes, 0); - for (const on of [false, true]) { - value = { mods: { left: [{ id: "ws", on }] } }; - context.savePending = true; - context.saveNow(); - assert.equal(context.writeInFlight, false); - assert.equal(context.savePending, false); - assert.equal(JSON.parse(disk).mods.left[0].on, on); - context.saveNow(); // Opening a form can schedule the same value again. - assert.equal(context.writeInFlight, false); - } - assert.equal(writes, 2); + assert.match(source, /if \(!document\.begin\(\)\)/); + assert.match(source, /candidate: writeSnapshot, version: SettingsHelpers\.VERSION/); + assert.match(source, /sameContent\(next, lastPersistedText\)/); + assert.doesNotMatch(source, /store\.setText/); + // Real QML asynchronous transitions are exercised in tst_settings_document, + // and settings-ownership.py exercises the actual atomic filesystem writer. }); diff --git a/tests/quickshell/system-theme-kitty.test.cjs b/tests/quickshell/system-theme-kitty.test.cjs index a74882e1..ec84eee1 100644 --- a/tests/quickshell/system-theme-kitty.test.cjs +++ b/tests/quickshell/system-theme-kitty.test.cjs @@ -66,8 +66,8 @@ test("the vendor fragment includes the generated file after its fallback", () => assert.equal(lines.at(-1), INCLUDE, "the include is the last setting, so it wins"); assert.equal(lines.filter(line => /^(glob|env|gen)?include /.test(line)).length, 1); const tasks = fs.readFileSync(path.join(repoRoot, "roles/dotfiles/tasks/personal.yml"), "utf8"); - assert.match(tasks, /src: kitty\.conf\n\s+dest: "\{\{ primary_home \}\}\/\.config\/kitty\/cybexos\.conf"/); - assert.match(tasks, /block: "include cybexos\.conf"/); + assert.match(tasks, /content:.*files\/kitty\.conf[^\n]*\n\s+dest: "\{\{ primary_home \}\}\/\.config\/kitty\/cybexos\.conf"/); + assert.match(tasks, /cybexos_user_include:[\s\S]*?kind: kitty/); }); test("kitty is signalled only when its file changed, or on --force", t => { diff --git a/tests/quickshell/t3-actions.test.cjs b/tests/quickshell/t3-actions.test.cjs new file mode 100644 index 00000000..13d1c5cf --- /dev/null +++ b/tests/quickshell/t3-actions.test.cjs @@ -0,0 +1,67 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const { shellDir, load } = require("./shell.cjs"); + +// Evaluate the production domain methods with a controlled wire boundary. +// The Qt lifecycle harness separately verifies process/event delivery. +function harness() { + const sent = []; + const context = { actionStates: {}, actionTimeoutMs: 15000, + supportsSettlement: true, supportsSnooze: true, + supportsTitleRegeneration: true, supportsPinning: true, + Helpers: load("T3CodeHelpers.js"), + T3Connection: { canOperate: true, state: "connected" }, + T3Threads: { threadMap: {} }, + T3Rpc: { genId: () => "command", requestOnce: (tag, payload, success, failure) => { + sent.push({ tag, payload, success, failure }); return String(sent.length); + } }, + console: { warn() {} }, Date, + }; + context.root = context; + vm.createContext(context); + const source = fs.readFileSync(path.join(shellDir, "Common/T3Actions.qml"), "utf8"); + for (const match of source.matchAll(/^ function \w+\([^]*?^ }/gm)) + vm.runInContext(match[0], context); + return { context, sent }; +} + +test("a partially accepted batch stops on rejection and is never replayed", () => { + const { context: c, sent } = harness(); + const commands = [{type:"one"}, {type:"two"}, {type:"three"}]; + c.dispatchBatch(commands, "batch", {}); + assert.equal(sent.length, 1); + assert.equal(c.actionStates.batch.pending, true); + sent[0].success(); + assert.equal(sent.length, 2); + sent[1].failure("Disconnected before confirmation"); + assert.equal(sent.length, 2); + assert.equal(c.actionStates.batch.pending, false); + assert.equal(c.actionStates.batch.error, "Disconnected before confirmation"); +}); + +test("duplicate pending actions and read-only connections cannot dispatch", () => { + const { context: c, sent } = harness(); + c.dispatch({type:"one"}, "same", true); + assert.equal(c.dispatch({type:"one"}, "same", true), ""); + assert.equal(sent.length, 1); + sent[0].success(); + assert.equal(c.actionStates.same.pending, true, "RPC acceptance alone cannot resolve an approval"); + c.T3Connection.canOperate = false; + c.dispatch({type:"two"}, "other", false); + assert.equal(sent.length, 1); + assert.match(c.actionStates.other.error, /read-only/); +}); + +test("action expiry affects pending feedback and retains earlier errors", () => { + const { context: c } = harness(); + c.beginAction("expired", "1", false, 20); + c.beginAction("running", "2", false, 50000); + c.rejectAction("failed", "Rejected", false); + c.expire(Date.now() + 30); + assert.equal(c.actionStates.expired.pending, false); + assert.equal(c.actionStates.running.pending, true); + assert.equal(c.actionStates.failed.error, "Rejected"); +}); diff --git a/tests/quickshell/widget-editor.test.cjs b/tests/quickshell/widget-editor.test.cjs index 097be3f3..8dc057db 100644 --- a/tests/quickshell/widget-editor.test.cjs +++ b/tests/quickshell/widget-editor.test.cjs @@ -81,7 +81,7 @@ function membershipHarness() { const settings = fs.readFileSync(path.join(shellDir,'Common/Settings.qml'),'utf8'); const timer = { restart() {}, stop() {} }; const context = vm.createContext({ - mods: structuredClone(mods), clearUndo() {}, migrationPending: false, + mods: structuredClone(mods), clearUndo() {}, markExplicit() {}, migrationPending: false, LayoutHelpers: L, membershipBusy: false, subPage: '', search: { text: '' }, detailPage: { contentY: 0 }, pendingMembership: null, undoRemoved: null, notice: '', announcement: '', membershipTimeout: timer, noticeTimer: timer, membershipFocus: { ...timer }, diff --git a/tests/quickshell/widget-options.test.cjs b/tests/quickshell/widget-options.test.cjs index 6315698e..21d6bdf5 100644 --- a/tests/quickshell/widget-options.test.cjs +++ b/tests/quickshell/widget-options.test.cjs @@ -11,6 +11,7 @@ function settingsHarness() { const context = vm.createContext({ ...H.defaults(), defaults: H.defaults(), SettingsHelpers: H, resetSnapshot: null, resetLabel: '', migrationPending: false, + explicitOverrides: [], resetOverrides: [], scheduleSave() {}, markExplicit() {}, resetTimer: { restart() {}, stop() {} }, sectionKeys: { drawer: ['drawerTabs', 'drawerOverview', 'drawerHover', 'drawerWidth'] } }); diff --git a/tests/repository-policy.py b/tests/repository-policy.py index 0b01f191..8930febe 100644 --- a/tests/repository-policy.py +++ b/tests/repository-policy.py @@ -217,9 +217,9 @@ def verify_dependency_policy(values: dict) -> None: assert "git" in required_commands.group(1).split() runner = (ROOT / "tests/run").read_text() - assert "rg -l '^#!.*(bash|sh)' -g '!*.j2' ." in runner + assert r"rg -Ul '\A#![^\n]*(bash|sh)' -g '!*.j2' ." in runner assert "rg --files . -g '*.py'" in runner - assert "rg -l '^#!.*python' -g '!*.j2' ." in runner + assert r"rg -Ul '\A#![^\n]*python' -g '!*.j2' ." in runner verifier = (ROOT / "tests/verify-system").read_text() assert not re.search( diff --git a/tests/run b/tests/run index 17040c70..09f0788c 100755 --- a/tests/run +++ b/tests/run @@ -221,9 +221,11 @@ stage_source_syntax() { # Give content searches an explicit root. Some ripgrep builds treat a # non-interactive empty stdin as the search input when no path is supplied, # which made CI discover zero shell sources despite a complete checkout. - mapfile -t bash_files < <(rg -l '^#!.*(bash|sh)' -g '!*.j2' . | LC_ALL=C sort) + # Only the first line declares a file's language; fixtures contain script + # heredocs whose embedded shebang must not classify the enclosing file. + mapfile -t bash_files < <(rg -Ul '\A#![^\n]*(bash|sh)' -g '!*.j2' . | LC_ALL=C sort) mapfile -t python_files < <( - { rg --files . -g '*.py'; rg -l '^#!.*python' -g '!*.j2' .; } | LC_ALL=C sort -u + { rg --files . -g '*.py'; rg -Ul '\A#![^\n]*python' -g '!*.j2' .; } | LC_ALL=C sort -u ) # Model Usage's vendored UsageLogic.js is a QML `.pragma library`, which # node cannot parse; tests/model-usage.py runs upstream's own test on it. @@ -309,6 +311,7 @@ stage_hyprland_workspaces() { r=0 bash tests/hyprland-workspaces || r=1 bash tests/hyprland-features || r=1 + bash tests/hyprland-input || r=1 exit "$r" } 2>&1) || rc=$? elapsed=$(( $(now_ms) - t0 )) @@ -467,13 +470,19 @@ python_fixtures=( bluetooth-tool firmware-update shell-health + shell-recovery repository-policy application-defaults installation-parity login-policy session-launcher + update-transaction + update-bootstrap + major-upgrade + native-input-region omawrite ownership-layering + settings-ownership plugin-packages model-usage installer-convergence diff --git a/tests/session-launcher.py b/tests/session-launcher.py index a7e57496..6a28971a 100644 --- a/tests/session-launcher.py +++ b/tests/session-launcher.py @@ -4,6 +4,7 @@ import os from pathlib import Path import shlex +import shutil import signal import subprocess import tempfile @@ -14,6 +15,25 @@ ROOT = Path(__file__).resolve().parents[1] +AUTOSTART_DRIVER = ''' +local start +local calls = 0 +hl = { + on = function(event, callback) + assert(event == "hyprland.start" and start == nil) + start = callback + end, + exec_cmd = function(command) + calls = calls + 1 + io.write(command) + end, +} +dofile(arg[1]) +assert(calls == 0, "services must wait for the compositor's startup event") +assert(start, "the session startup callback was not registered") +start() +assert(calls == 1, "session publication must stay in one ordered process") +''' COMPOSITOR = '''#!/usr/bin/python3 import json, os, signal, sys from pathlib import Path @@ -28,6 +48,89 @@ ''' +class SessionAutostartTests(unittest.TestCase): + """Execute the startup event against each installation's helper layout. + + Development mode on an ISO reads the unmodified checkout Lua, bypassing + the image packager's /usr/local/libexec -> /usr/libexec rewriting. + """ + + def exercise(self, layout, *, missing=False, nonexecutable_local=False): + with tempfile.TemporaryDirectory(prefix='cybex-session-start.') as directory: + root = Path(directory) / 'fixture root' + binaries = root / 'bin' + binaries.mkdir(parents=True) + log = root / 'calls.log' + local = root / 'usr/local/libexec/cybexos-hyprland-session-start' + packaged = root / 'usr/libexec/cybexos-hyprland-session-start' + starter = local if layout == 'checkout' else packaged + if not missing: + starter.parent.mkdir(parents=True) + shutil.copyfile(ROOT / 'roles/desktop/files/hyprland-session-start', starter) + starter.chmod(0o755) + if nonexecutable_local: + local.parent.mkdir(parents=True) + local.write_text('not executable\n') + local.chmod(0o644) + source = (ROOT / 'roles/desktop/files/autostart.lua').read_text() + if layout == 'image': + # Keep the packaging rule tied to the actual image builder; + # the development case deliberately omits this transform. + rule = 'content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")' + self.assertIn(rule, (ROOT / 'image/package').read_text()) + source = source.replace('/usr/local/libexec/cybexos-', '/usr/libexec/cybexos-') + autostart = root / 'autostart.lua' + autostart.write_text(source) + selected = subprocess.run(['luajit', '-', str(autostart)], input=AUTOSTART_DRIVER, + text=True, capture_output=True, timeout=5) + self.assertEqual(selected.returncode, 0, selected.stderr) + command = selected.stdout + # Redirect only absolute helper paths into this disposable tree; + # execute the emitted shell logic and real ordered starter. + for prefix in ('/usr/local/libexec', '/usr/libexec'): + command = command.replace(prefix, shlex.quote(str(root / prefix.lstrip('/')))) + for name in ('systemctl', 'dbus-update-activation-environment', 'sleep'): + executable = binaries / name + executable.write_text('#!/bin/sh\n' + f'printf "%s %s\\n" {shlex.quote(name)} "$*" >>"$SESSION_TEST_LOG"\n') + executable.chmod(0o755) + environment = dict(os.environ, PATH=f'{binaries}:/usr/bin:/bin', + XDG_RUNTIME_DIR=str(root / 'run'), SESSION_TEST_LOG=str(log), + WAYLAND_DISPLAY='wayland-fixture', XDG_CURRENT_DESKTOP='Hyprland', + HYPRLAND_INSTANCE_SIGNATURE='fixture') + result = subprocess.run(['sh', '-c', command], env=environment, + text=True, capture_output=True, timeout=5) + if missing: + self.assertNotEqual(result.returncode, 0) + self.assertIn('cybexos-hyprland-session-start', result.stderr) + self.assertFalse(log.exists()) + return [] + self.assertEqual(result.returncode, 0, result.stderr) + calls = log.read_text().splitlines() + self.assertEqual(calls, [ + 'systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE', + 'dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE', + 'systemctl --user start hyprland-session.target', + 'sleep 1', + 'systemctl --user restart xdg-desktop-portal-hyprland.service xdg-desktop-portal.service', + ]) + return calls + + def test_checkout_image_and_image_development_start_the_same_session(self): + expected = self.exercise('checkout') + for layout in ('image', 'image-development'): + with self.subTest(layout=layout): + self.assertEqual(self.exercise(layout), expected) + + def test_image_development_ignores_nonexecutable_local_helper(self): + self.exercise('image-development', nonexecutable_local=True) + + def test_missing_helper_fails_without_starting_session_services(self): + for layout in ('checkout', 'image', 'image-development'): + with self.subTest(layout=layout): + self.exercise(layout, missing=True) + + class SessionLauncherTests(unittest.TestCase): def exercise(self, image, behavior): with tempfile.TemporaryDirectory(prefix='cybex-session-launcher.') as directory: diff --git a/tests/settings-ownership.py b/tests/settings-ownership.py new file mode 100644 index 00000000..73c72ac9 --- /dev/null +++ b/tests/settings-ownership.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Lossless settings writes and actual application include precedence.""" +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def load(name, relative): + loader = importlib.machinery.SourceFileLoader(name, str(ROOT / relative)) + spec = importlib.util.spec_from_loader(name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +STORE = load('settings_store', 'roles/desktop/files/quickshell/scripts/settings-store') +MANAGED = load('managed_file', 'image/library/cybexos_managed_file.py') +INCLUDE = load('user_include', 'image/library/cybexos_user_include.py') + + +class SettingsOwnership(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='cybexos-settings-ownership-') + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + self.path = self.home / 'shell.json' + + def test_independent_concurrent_edit_survives(self): + base = {'v': 27, 'unit': 'c', 'themeMode': 'dark', 'future': {'data': [1]}} + local = {**base, 'unit': 'f'} + other = {**base, 'themeMode': 'light', 'future': {'data': [2]}} + self.path.write_text(json.dumps(other)) + saved = json.loads(STORE.commit(self.path, json.dumps(base), json.dumps(local), 27)) + self.assertEqual(saved, {**other, 'unit': 'f'}) + self.assertEqual(json.loads(self.path.read_text()), saved) + + def test_conflict_preserves_disk_and_pending_candidate(self): + base = {'v': 27, 'barHeight': 36} + desired = {'v': 27, 'barHeight': 40} + current = json.dumps({'v': 27, 'barHeight': 44}) + self.path.write_text(current) + with self.assertRaisesRegex(ValueError, '/barHeight'): + STORE.commit(self.path, json.dumps(base), json.dumps(desired), 27) + self.assertEqual(self.path.read_text(), current) + copies = list(self.home.glob('shell.json.conflict-*')) + self.assertEqual(len(copies), 1) + self.assertEqual(json.loads(copies[0].read_text()), desired) + + def test_newer_schema_blocks_writes_even_if_loaded_file_was_older(self): + future = '{"v":28,"future":{"opaque":true}}' + self.path.write_text(future) + with self.assertRaisesRegex(ValueError, 'newer shell'): + STORE.commit(self.path, '{"v":27}', '{"v":27,"unit":"f"}', 27) + self.assertEqual(self.path.read_text(), future) + + def test_migration_keeps_exact_original_and_unknown_fields(self): + original = '{ "v": 3, "barHeight":30, "unknown":{"unparsed":true} }\n' + self.path.write_text(original) + candidate = json.loads(original) + candidate.update(v=27, unit='f') + STORE.commit(self.path, original, json.dumps(candidate), 27) + backups = list(self.home.glob('shell.json.before-migration-*')) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_text(), original) + self.assertEqual(json.loads(self.path.read_text())['unknown'], {'unparsed': True}) + + def test_nested_merge_and_explicit_reset_preserve_other_edits(self): + base = {'v': 27, 'unit': 'c', 'modOpts': {'weather': {'place': 'Home', 'pollMins': 10}}} + local = {'v': 27, 'modOpts': {'weather': {'place': 'Home', 'pollMins': 20}}} + other = {'v': 27, 'unit': 'c', 'modOpts': {'weather': {'place': 'Away', 'pollMins': 10}}} + merged = STORE.merge(base, local, other) + self.assertNotIn('unit', merged) + self.assertEqual(merged['modOpts']['weather'], {'place': 'Away', 'pollMins': 20}) + + def test_owned_fragments_update_but_customization_and_deletion_survive(self): + ledger = self.home / 'state/ownership.json' + target = self.home / 'kitty/cybexos.conf' + self.assertTrue(MANAGED.manage(target, b'first\n', ledger)['changed']) + self.assertTrue(MANAGED.manage(target, b'second\n', ledger)['changed']) + target.write_bytes(b'custom\n') + self.assertTrue(MANAGED.manage(target, b'third\n', ledger)['preserved']) + self.assertEqual(target.read_bytes(), b'custom\n') + target.unlink() + self.assertTrue(MANAGED.manage(target, b'third\n', ledger)['preserved']) + self.assertFalse(target.exists()) + self.assertEqual(len(list((ledger.parent / 'backups').rglob('*/*'))), 1) + + def test_user_edited_include_block_is_not_replaced_or_removed(self): + original = INCLUDE.BEGIN + '\ninclude personal.conf\n' + INCLUDE.END + '\n' + self.path.write_text(original) + self.assertTrue(INCLUDE.update(self.path, 'kitty')['preserved']) + self.assertTrue(INCLUDE.update(self.path, 'kitty', absent=True)['preserved']) + self.assertEqual(self.path.read_text(), original) + + def test_relocation_is_idempotent_and_preserves_the_original(self): + old = 'font_size 17\n' + INCLUDE.BEGIN + '\ninclude cybexos.conf\n' + INCLUDE.END + '\n' + self.path.write_text(old) + self.assertTrue(INCLUDE.update(self.path, 'kitty')['changed']) + self.assertFalse(INCLUDE.update(self.path, 'kitty')['changed']) + self.assertTrue(self.path.read_text().startswith(INCLUDE.BEGIN)) + self.assertEqual(next(self.home.glob('shell.json.cybexos-before-*')).read_text(), old) + + def test_git_user_values_win_in_the_actual_parser(self): + vendor = self.home / '.config/cybexos/gitconfig' + vendor.parent.mkdir(parents=True) + vendor.write_text('[core]\n pager = delta\n') + target = self.home / '.gitconfig' + target.write_text('[core]\n pager = personal-pager\n[user]\n name = Personal\n') + INCLUDE.update(target, 'git') + target.write_text(target.read_text().replace('~/.config', str(self.home / '.config'))) + self.assertEqual(subprocess.check_output(['git', 'config', '--file', str(target), '--includes', + '--get', 'core.pager'], text=True).strip(), 'personal-pager') + + def test_personal_git_helper_chain_is_detected_through_nested_includes(self): + vendor = self.home / '.config/cybexos/gitconfig' + vendor.parent.mkdir(parents=True) + vendor.write_text('[credential "https://github.com"]\n helper = vendor-helper\n') + target = self.home / '.gitconfig' + target.write_text('[include]\n path = ' + str(vendor) + '\n') + self.assertFalse(INCLUDE.personal_git_credentials(target)) + personal = self.home / 'personal.gitconfig' + personal.write_text('[credential "https://github.com"]\n helper = personal-helper\n') + target.write_text(target.read_text() + '[include]\n path = ' + str(personal) + '\n') + self.assertTrue(INCLUDE.personal_git_credentials(target)) + + def test_ssh_user_first_value_and_final_host_scope(self): + vendor = self.home / '.config/cybexos/ssh.conf' + vendor.parent.mkdir(parents=True) + vendor.write_text('Host *\n IdentityAgent /vendor/agent.sock\n ServerAliveInterval 17\n') + target = self.home / '.ssh/config' + target.parent.mkdir() + target.write_text('Host example\n IdentityAgent /personal/agent.sock\nHost different\n User custom\n') + INCLUDE.update(target, 'ssh') + target.write_text(target.read_text().replace('~/.config', str(self.home / '.config'))) + actual = subprocess.check_output(['ssh', '-G', '-F', str(target), 'example'], text=True, + stderr=subprocess.DEVNULL) + self.assertIn('identityagent /personal/agent.sock\n', actual) + self.assertIn('serveraliveinterval 17\n', actual) + + @unittest.skipUnless(shutil.which('kitty'), 'Kitty config parser is not installed') + def test_kitty_user_last_value_in_the_actual_parser(self): + target = self.home / 'kitty.conf' + (self.home / 'cybexos.conf').write_text('font_size 12\n') + target.write_text('font_size 17\n') + INCLUDE.update(target, 'kitty') + code = 'from kitty.config import load_config; print(load_config(' + repr(str(target)) + ').font_size)' + self.assertEqual(float(subprocess.check_output(['kitty', '+runpy', code], text=True).strip()), 17) + + def test_checkout_and_image_share_the_same_personal_policy(self): + personal = yaml.safe_load((ROOT / 'roles/dotfiles/tasks/personal.yml').read_text()) + by_name = {task['name']: task for task in personal} + xdg = by_name['Configure XDG user directories']['ansible.builtin.copy'] + self.assertFalse(xdg['force']) + for task in personal: + if 'cybexos_managed_file' in task: + self.assertEqual(task['become_user'], '{{ primary_user }}') + self.assertIn('/.local/state/cybexos/defaults/', task['cybexos_managed_file']['ledger']) + provision = (ROOT / 'image/provision.yml').read_text() + self.assertIn('tasks_from: personal', provision) + self.assertIn('library = image/library', (ROOT / 'ansible.cfg').read_text()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/shell-recovery.py b/tests/shell-recovery.py new file mode 100644 index 00000000..22852a6c --- /dev/null +++ b/tests/shell-recovery.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Exercise crash accounting and recovery with isolated state; never start qs.""" +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "roles/desktop/files/quickshell/scripts/shell-recovery.py" +SPEC = importlib.util.spec_from_file_location("shell_recovery", SCRIPT) +assert SPEC and SPEC.loader +M = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(M) + + +class RecoveryTest(unittest.TestCase): + def test_failures_select_safe_mode_on_fourth_launch(self): + state = {"version": 1, "safe": False, "failures": []} + for count in range(3): + state = M.prepare(state, 10 * count, "boot", str(count)) + state = M.record_stop(state, 10 * count + 1, "boot", str(count), "signal", "killed", "SEGV") + self.assertEqual(bool(state.get("safe")), count == 2) + state = M.prepare(state, 31, "boot", "safe") + self.assertTrue(state["safe"]) + + def test_normal_restart_long_run_and_boot_do_not_form_crash_loop(self): + state = {"version": 1, "safe": False, "failures": []} + for count in range(6): + state = M.prepare(state, count * 5, "boot", str(count)) + state = M.record_stop(state, count * 5 + 1, "boot", str(count), "success", "exited", "0") + self.assertFalse(state["safe"]) + state = M.prepare(state, 100, "boot", "fail") + state = M.record_stop(state, 101, "boot", "fail", "exit-code", "exited", "1") + state = M.prepare(state, 105, "boot", "long") + state = M.record_stop(state, 300, "boot", "long", "signal", "killed", "SEGV") + self.assertEqual(state["failures"], []) + state["failures"] = [300, 305] + self.assertEqual(M.prepare(state, 1, "new-boot", "next")["failures"], []) + + def test_stale_or_duplicate_stop_cannot_count_twice(self): + state = M.prepare({"version": 1, "safe": False, "failures": []}, 0, "boot", "new") + self.assertEqual(M.record_stop(state, 1, "boot", "old", "signal", "killed", "SEGV"), state) + stopped = M.record_stop(state, 1, "boot", "new", "signal", "killed", "SEGV") + self.assertEqual(M.record_stop(stopped, 2, "boot", "new", "signal", "killed", "SEGV"), stopped) + + def test_launcher_execs_selected_shell_without_an_extra_supervisor(self): + with tempfile.TemporaryDirectory(prefix="cybexos-shell-exec.") as scratch: + runtime = ROOT / "roles/desktop/files/quickshell" + with patch.dict(os.environ, {"XDG_STATE_HOME": scratch, "INVOCATION_ID": "test"}): + M.main(["safe", str(runtime)]) + with patch.object(M.os, "execv") as execute: + M.main(["run", str(runtime)]) + execute.assert_called_once_with("/usr/bin/qs", ["qs", "-p", str(runtime / "safe-mode")]) + self.assertEqual(M.read_state(M.state_path())["invocation"], "test") + + def test_commands_preserve_config_and_roundtrip_state(self): + with tempfile.TemporaryDirectory(prefix="cybexos-shell-recovery.") as scratch: + root = Path(scratch) + config = root / "config/cybexos" + config.mkdir(parents=True) + personal = {"shell.json": '{"v":26,"font":"mine"}', "plugins.json": '{"enabled":["mine"]}'} + for name, content in personal.items(): + (config / name).write_text(content) + env = dict(os.environ, XDG_STATE_HOME=str(root / "state"), XDG_CONFIG_HOME=str(root / "config"), HOME=str(root)) + runtime = ROOT / "roles/desktop/files/quickshell" + def call(action): + return subprocess.check_output(["python3", "-B", str(SCRIPT), action, str(runtime)], env=env, text=True) + call("safe") + status = json.loads(call("status")) + self.assertTrue(status["safe"]) + self.assertEqual(call("path").strip(), str(runtime / "safe-mode")) + self.assertEqual(Path(status["path"]).stat().st_mode & 0o777, 0o600) + call("recover") + self.assertFalse(json.loads(call("status"))["safe"]) + self.assertEqual(call("path").strip(), str(runtime)) + for name, content in personal.items(): + self.assertEqual((config / name).read_text(), content) + Path(status["path"]).write_text("{broken") + self.assertTrue(json.loads(call("status"))["safe"]) + call("recover") + self.assertFalse(json.loads(call("status"))["safe"]) + self.assertEqual(list((root / "state/cybexos").glob(".shell-recovery-*")), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system-settings-live b/tests/system-settings-live index a70e3f37..78ff6f45 100755 --- a/tests/system-settings-live +++ b/tests/system-settings-live @@ -19,17 +19,18 @@ cleanup() { exit "$status" } trap cleanup EXIT -for page in sound network accounts network; do +for page in sound network accounts keyboard touchpad region network; do qs_live_wait_ipc 10 settings open "$page" >/dev/null ready=false - for _ in {1..40}; do + for _ in {1..100}; do state=$(qs_live_wait_ipc 5 settings status) if python3 - "$state" "$page" <<'PY' import json, sys state, page = json.loads(sys.argv[1]), sys.argv[2] -service = state['services'][page] +domain = 'input' if page in ('keyboard', 'touchpad') else page +service = state['services'][domain] sys.exit(0 if state['open'] and state['page'] == page and service['loaded'] - and service['watchers'] == 1 and service['watching'] and not service['failed'] + and service['watchers'] == 1 and service['watching'] == (domain not in ('input', 'region')) and not service['failed'] and not service['busy'] and not service['loading'] else 1) PY then ready=true; break; fi diff --git a/tests/update-bootstrap.py b/tests/update-bootstrap.py new file mode 100644 index 00000000..b8a0df2e --- /dev/null +++ b/tests/update-bootstrap.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Exercise first-upgrade recovery deployment without touching the host.""" +import contextlib +import importlib.machinery +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import tempfile +import types +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +loader = importlib.machinery.SourceFileLoader('bootstrap', str( + ROOT / 'roles/base/files/cybexos-update-bootstrap')) +spec = importlib.util.spec_from_loader(loader.name, loader) +bootstrap = importlib.util.module_from_spec(spec) +loader.exec_module(bootstrap) + + +class Bootstrap(unittest.TestCase): + def setUp(self): + temporary = self.enterContext(tempfile.TemporaryDirectory(prefix='cybexos-bootstrap-test.')) + self.fixture = Path(temporary) + self.host = self.fixture / 'system' + self.host.mkdir() + self.source = self.fixture / 'source' + self.source.mkdir() + for name in bootstrap.FILES: + shutil.copyfile(ROOT / 'roles/base/files' / name, self.source / name) + self.point = '20260930T140000Z-100' + self.protected = '20260930T140001Z-101' + self.store = types.SimpleNamespace(path=self.fixture / 'store', roots=self.fixture / 'snapshots') + self.store.path.mkdir() + self.store.roots.mkdir() + (self.store.roots / self.point).mkdir() + self.layout = types.SimpleNamespace(usable=True, pending_reboot=False, kind='btrfs') + self.snapshot = types.SimpleNamespace(detect_layout=lambda: self.layout, + opened_store=self.opened_store, + command_create=self.create_snapshot) + self.commands = [] + self.enabled = False + for key, value in ( + ('HOST', self.host), ('ROOT_UID', os.getuid()), + ('BUNDLES', self.host / 'usr/local/libexec/cybexos-update-bootstrap.d'), + ('UNIT', self.host / 'etc/systemd/system/cybexos-update-recover.service'), + ('VENDOR_UNIT', self.host / 'usr/lib/systemd/system/cybexos-update-recover.service'), + ('snapshot_module', lambda _contents: self.snapshot), ('execute', self.execute), + ): + self.enterContext(patch.object(bootstrap, key, value)) + + @contextlib.contextmanager + def opened_store(self, _layout, create=False): + yield self.store + + def execute(self, command): + self.commands.append(command) + if command[:2] == ['systemctl', 'enable']: + self.enabled = True + if command[:2] == ['systemctl', 'is-enabled']: + return 'enabled' if self.enabled else 'disabled' + if command[:2] == ['systemctl', 'show']: + unit = command[2] + property_name = command[4] + if property_name == 'ExecStart': + file = bootstrap.UNIT if bootstrap.UNIT.exists() else bootstrap.VENDOR_UNIT + text = file.read_text() + start = next(line.split('=', 1)[1] for line in text.splitlines() if line.startswith('ExecStart=')) + return '{ path=' + start + ' ; argv[]=' + start + ' ; }' + barrier = bootstrap.UNIT.parent / f'{unit}.d/60-cybexos-update-recover.conf' + if barrier.exists() and f'{property_name}=cybexos-update-recover.service' in barrier.read_text(): + return 'cybexos-update-recover.service' + return '' + return '' + + def create_snapshot(self, _arguments): + # The actual point captures installed code and login barriers before + # any journal can permit package/home mutation. Root restoration must + # recover this hook, not the pre-feature root without one. + shutil.copytree(self.host, self.store.roots / self.protected) + print(self.protected) + + def prepare(self): + return bootstrap.prepare(self.source, self.point, 'update-fixture') + + def test_first_upgrade_captures_a_self_contained_root_owned_recovery_hook(self): + result = self.prepare() + self.assertEqual(result['snapshot'], self.protected) + self.assertEqual(result['previousSnapshot'], self.point) + bundle = Path(result['transactionHelper']).parent + bootstrap.ready(bundle) + preserved_root = self.store.roots / self.protected + preserved_unit = preserved_root / bootstrap.UNIT.relative_to(self.host) + self.assertIn(f'ExecStart={bundle}/cybexos-update-recover', preserved_unit.read_text()) + for name in bootstrap.FILES: + preserved = preserved_root / bundle.relative_to(self.host) / name + self.assertEqual(preserved.read_bytes(), (self.source / name).read_bytes()) + self.assertEqual(preserved.stat().st_mode & 0o022, 0) + for unit in ('systemd-user-sessions.service', 'sddm.service'): + barrier = preserved_unit.parent / f'{unit}.d/60-cybexos-update-recover.conf' + self.assertIn('Requires=cybexos-update-recover.service', barrier.read_text()) + self.assertEqual(list((self.store.roots / self.point).iterdir()), [], 'untouched original point') + (self.source / 'cybexos-update-transaction').write_text('changed user checkout') + self.assertNotEqual((bundle / 'cybexos-update-transaction').read_text(), 'changed user checkout') + + def test_missing_checkpoint_refuses_before_any_installed_write(self): + (self.store.roots / self.point).rmdir() + with self.assertRaisesRegex(bootstrap.Failure, 'missing'): + self.prepare() + self.assertEqual(list(self.host.iterdir()), []) + self.assertEqual(self.commands, []) + + def test_unfinished_journal_blocks_bootstrap_and_cleanup(self): + result = self.prepare() + journal = self.store.path / 'transactions/earlier/transaction.json' + journal.parent.mkdir(parents=True) + journal.write_text(json.dumps({'state': 'rolling-back'})) + with self.assertRaisesRegex(bootstrap.Failure, 'earlier update'): + self.prepare() + bundle = Path(result['transactionHelper']).parent + with self.assertRaisesRegex(bootstrap.Failure, 'unfinished update'): + bootstrap.finalize(bundle) + self.assertTrue(bundle.is_dir()) + + def test_login_barrier_and_enablement_are_required(self): + result = self.prepare() + bundle = Path(result['transactionHelper']).parent + self.enabled = False + with self.assertRaisesRegex(bootstrap.Failure, 'not enabled'): + bootstrap.ready(bundle) + self.enabled = True + barrier = bootstrap.UNIT.parent / 'sddm.service.d/60-cybexos-update-recover.conf' + barrier.unlink() + with self.assertRaisesRegex(bootstrap.Failure, 'barrier'): + bootstrap.ready(bundle) + + def test_snapshot_failure_leaves_boot_recovery_but_no_package_mutation(self): + with patch.object(self.snapshot, 'command_create', side_effect=OSError('snapshot failed')): + with self.assertRaisesRegex(OSError, 'snapshot failed'): + self.prepare() + self.assertTrue(bootstrap.UNIT.is_file()) + self.assertFalse((self.store.roots / self.protected).exists()) + self.assertTrue(all(command[0] in {'sync', 'systemctl', 'restorecon'} for command in self.commands)) + + def test_source_and_destination_symlinks_fail_closed(self): + target = self.source / 'cybexos-update-transaction' + target.unlink() + target.symlink_to(ROOT / 'roles/base/files/cybexos-update-transaction') + with self.assertRaises(OSError): + self.prepare() + self.assertEqual(list(self.host.iterdir()), []) + target.unlink() + shutil.copyfile(ROOT / 'roles/base/files/cybexos-update-transaction', target) + (self.host / 'usr').symlink_to(self.fixture / 'escape') + with self.assertRaises((OSError, bootstrap.Failure)): + self.prepare() + self.assertFalse((self.fixture / 'escape').exists()) + + def test_bundle_corruption_is_not_reused(self): + result = self.prepare() + helper = Path(result['transactionHelper']) + helper.write_text('unexpected change') + with self.assertRaisesRegex(bootstrap.Failure, 'changed'): + self.prepare() + + def install_normal(self, rpm=False): + libexec = self.host / ('usr/libexec' if rpm else 'usr/local/libexec') + bootstrap.directory(libexec) + for name in bootstrap.FILES[:6]: + bootstrap.atomic_write(libexec / name, (self.source / name).read_bytes(), + 0o644 if name.endswith('.json') else 0o755) + unit = (self.source / 'cybexos-update-recover.service').read_text() + unit = unit.replace('/usr/local/libexec/', str(libexec) + '/') + bootstrap.atomic_write(bootstrap.VENDOR_UNIT if rpm else bootstrap.UNIT, unit.encode()) + return libexec + + def test_source_convergence_retires_only_the_bootstrap_bundle(self): + result = self.prepare() + bundle = Path(result['transactionHelper']).parent + normal = self.install_normal() + bootstrap.finalize(bundle) + self.assertFalse(bundle.exists()) + bootstrap.ready(normal) + self.assertTrue((self.store.roots / self.protected / bundle.relative_to(self.host)).is_dir()) + + def test_rpm_convergence_removes_only_the_bootstrap_unit_override(self): + result = self.prepare() + bundle = Path(result['transactionHelper']).parent + normal = self.install_normal(rpm=True) + bootstrap.finalize(bundle) + self.assertFalse(bundle.exists()) + self.assertFalse(bootstrap.UNIT.exists()) + bootstrap.ready(normal) + + def test_package_only_update_keeps_its_required_recovery_implementation(self): + result = self.prepare() + bundle = Path(result['transactionHelper']).parent + with contextlib.redirect_stderr(io.StringIO()) as warning: + bootstrap.finalize(bundle) + self.assertIn('retained', warning.getvalue()) + bootstrap.ready(bundle) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/update-transaction.py b/tests/update-transaction.py new file mode 100644 index 00000000..606921e9 --- /dev/null +++ b/tests/update-transaction.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python3 +"""Exercise the durable recovery journal against disposable filesystem state.""" +import contextlib +import importlib.machinery +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import types +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +loader = importlib.machinery.SourceFileLoader('transaction', str( + ROOT / 'roles/base/files/cybexos-update-transaction')) +spec = importlib.util.spec_from_loader(loader.name, loader) +transaction = importlib.util.module_from_spec(spec) +loader.exec_module(transaction) + + +class Recovery(unittest.TestCase): + def setUp(self): + self.enterContext(contextlib.redirect_stdout(io.StringIO())) + self.temp = tempfile.TemporaryDirectory(prefix='cybexos-transaction-test.') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.home = self.root / 'home' + self.home.mkdir() + self.store = transaction.snapshot.Store(self.root) + for directory in (self.store.roots, self.store.boot, self.store.metadata, self.store.replaced): + directory.mkdir(parents=True, exist_ok=True) + self.point = '20260930T100000Z-123' + (self.store.roots / self.point).mkdir() + self.layout = types.SimpleNamespace(usable=True, kind='btrfs', pending_reboot=False, + uuid='fixture') + self.vendor = ['.local/share/cybexos/runtime', '.local/share/cybexos/current', '.local/bin/helper'] + (self.home / self.vendor[0]).mkdir(parents=True) + (self.home / self.vendor[0] / 'shell.qml').write_text('old desktop') + (self.home / self.vendor[1]).symlink_to('releases/1.0.0') + self.personal = self.home / '.config/cybexos/shell.json' + self.personal.parent.mkdir(parents=True) + self.personal.write_text('{"personal": true}') + for name, value in ( + ('vendor_paths', lambda: self.vendor), + ('failed_units', lambda: []), + ('desktop_active', lambda _uid: False), + ('boot_id', lambda: 'old-boot'), + ('health', lambda _record, **_options: None), + ): + mocked = patch.object(transaction, name, value) + mocked.start() + self.addCleanup(mocked.stop) + account = types.SimpleNamespace(pw_dir=str(self.home), pw_name='fixture', pw_gid=os.getgid()) + for target, name, value in ( + (transaction.pwd, 'getpwuid', lambda _uid: account), + (transaction.snapshot, 'detect_layout', lambda: self.layout), + (transaction.snapshot, 'opened_store', self.opened_store), + (transaction.snapshot, 'subvolume_uuid', lambda _path: 'original-root'), + (transaction.snapshot, 'complete_interrupted', lambda _store: None), + (transaction.snapshot, 'command_restore', self.restore), + ): + mocked = patch.object(target, name, value) + mocked.start() + self.addCleanup(mocked.stop) + self.restores = 0 + + @contextlib.contextmanager + def opened_store(self, _layout, create=False): + yield self.store + + def restore(self, _args): + self.restores += 1 + self.layout.pending_reboot = True + path = self.store.replaced / 'root.replaced-20260930T100100Z.json' + path.write_text(json.dumps({'point': self.point, 'state': 'complete'})) + + def begin(self): + transaction.begin('update-fixture', self.point, os.getuid()) + + def record(self): + return transaction.read(self.store.path / 'transactions/update-fixture') + + def test_boot_home_operations_drop_identity_without_opening_a_pam_session(self): + command = ['/usr/bin/tar', '--list', '--file=-'] + with patch.object(transaction.os, 'geteuid', return_value=0): + dropped = transaction.home_command(1201, command) + self.assertEqual(dropped, ['/usr/bin/setpriv', '--reuid', '1201', '--regid', str(os.getgid()), + '--init-groups', '--', '/usr/bin/env', f'HOME={self.home}', + 'USER=fixture', 'LOGNAME=fixture', *command]) + with patch.object(transaction.os, 'geteuid', return_value=1201): + self.assertEqual(transaction.home_command(1201, command), command) + + def test_commit_validates_and_releases_checkpoint_and_pin(self): + self.begin() + transaction.transition('update-fixture', 'applying') + with patch.object(transaction, 'health') as health: + transaction.transition('update-fixture', 'commit') + health.assert_called_once() + self.assertEqual(self.record()['state'], 'committed') + self.assertFalse((self.store.metadata / f'{self.point}.pin').exists()) + self.assertFalse((self.store.path / 'transactions/update-fixture/vendor').exists()) + + def test_health_failure_keeps_checkpoint_until_automatic_rollback(self): + self.begin() + transaction.transition('update-fixture', 'applying') + (self.home / self.vendor[0] / 'shell.qml').write_text('broken new desktop') + self.personal.write_text('{"personal": "edited while updating"}') + (self.home / '.local/bin').mkdir(parents=True) + (self.home / '.local/bin/helper').write_text('new helper') + with patch.object(transaction, 'health', side_effect=transaction.snapshot.Failure('bad QML')): + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'commit') + transaction.rollback('update-fixture') + self.assertEqual(self.restores, 1) + self.assertEqual(self.record()['state'], 'rolled-back') + self.assertTrue(self.record()['restartRequired']) + self.assertEqual((self.home / self.vendor[0] / 'shell.qml').read_text(), 'old desktop') + self.assertFalse((self.home / '.local/bin/helper').exists()) + self.assertEqual(self.personal.read_text(), '{"personal": "edited while updating"}') + self.assertEqual(os.readlink(self.home / self.vendor[1]), 'releases/1.0.0') + transaction.rollback('update-fixture') + self.assertEqual(self.restores, 1, 'retry must not exchange roots twice') + + def test_terminal_cleanup_failure_cannot_reopen_a_completed_transaction(self): + for action, terminal in (('commit', 'committed'), ('rollback', 'rolled-back')): + with self.subTest(action=action): + identifier = 'cleanup-' + action + transaction.begin(identifier, self.point, os.getuid()) + transaction.transition(identifier, 'applying') + with patch.object(transaction, 'remove', side_effect=OSError('cleanup interrupted')): + with contextlib.redirect_stderr(io.StringIO()) as errors: + if action == 'rollback': + transaction.rollback(identifier) + else: + transaction.transition(identifier, action) + self.assertIn('retained cleanup artifact', errors.getvalue()) + record = transaction.read(self.store.path / 'transactions' / identifier) + self.assertEqual(record['state'], terminal) + with patch.object(transaction, 'rollback') as retry: + transaction.recover() + retry.assert_not_called() + + def test_interrupted_vendor_restore_retries_without_second_root_exchange(self): + self.begin() + transaction.transition('update-fixture', 'applying') + with patch.object(transaction, 'restore_vendor', side_effect=OSError('interrupted')): + with self.assertRaises(OSError): + transaction.rollback('update-fixture') + self.assertEqual(self.record()['state'], 'rollback-failed') + transaction.rollback('update-fixture') + self.assertEqual(self.restores, 1) + self.assertEqual(self.record()['state'], 'rolled-back') + + def test_interrupted_restore_completed_after_reboot_never_exchanges_root_again(self): + self.begin() + transaction.transition('update-fixture', 'applying') + with patch.object(transaction, 'restore_vendor', side_effect=OSError('power lost')): + with self.assertRaises(OSError): + transaction.rollback('update-fixture') + # The exchange completed before power failed, and the next boot uses + # its restored root; only the user-owned vendor archive needs replay. + self.layout.pending_reboot = False + self.personal.write_text('{"edited after restart": true}') + with patch.object(transaction, 'boot_id', return_value='new-boot'): + transaction.rollback('update-fixture') + self.assertEqual(self.restores, 1) + self.assertFalse(self.record()['restartRequired']) + self.assertEqual(self.personal.read_text(), '{"edited after restart": true}') + + def test_recovery_from_a_failed_boot_still_requires_restart_after_root_exchange(self): + self.begin() + transaction.transition('update-fixture', 'applying') + with patch.object(transaction, 'boot_id', return_value='failed-new-boot'): + with self.assertRaises(SystemExit) as error: + transaction.recover() + self.assertEqual(error.exception.code, 75) + self.assertTrue(self.record()['restartRequired']) + self.assertEqual(self.restores, 1) + + def test_a_failed_sync_leaves_the_durable_applying_record_recoverable(self): + self.begin() + original_execute = transaction.execute + + def fail_journal_sync(command, **options): + if command[:2] == ['sync', '-f']: + raise transaction.snapshot.Failure('simulated filesystem sync failure') + return original_execute(command, **options) + + with patch.object(transaction, 'execute', side_effect=fail_journal_sync): + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'applying') + self.assertEqual(self.record()['state'], 'applying') + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'abort') + transaction.rollback('update-fixture') + self.assertEqual(self.record()['state'], 'rolled-back') + self.assertEqual(self.restores, 1) + + def test_major_upgrade_waits_for_new_boot_then_explicit_desktop_validation(self): + self.begin() + original = self.personal.read_bytes() + metadata = {'targetFedora': '45', 'source': 'fixture-reviewed-release'} + transaction.transition('update-fixture', 'arm-upgrade', metadata) + self.assertEqual(self.record()['state'], 'awaiting-upgrade') + self.assertEqual(self.record()['upgrade'], metadata) + with patch.object(transaction.subprocess, 'run') as finalize: + transaction.recover() + finalize.assert_not_called() + transaction.transition('update-fixture', 'applying') + with patch.object(transaction, 'health') as check: + transaction.transition('update-fixture', 'await-desktop') + check.assert_called_once() + self.assertEqual(check.call_args.kwargs, {'desktop': False}) + self.assertEqual(self.record()['state'], 'awaiting-desktop') + self.assertTrue(self.record()['desktopActive']) + self.assertTrue((self.store.metadata / f'{self.point}.pin').exists()) + self.assertTrue((self.store.path / 'transactions/update-fixture/vendor').is_dir()) + with patch.object(transaction.subprocess, 'run') as finalize: + with patch.object(transaction, 'boot_id', return_value='another-boot'): + transaction.recover() + finalize.assert_not_called() + self.assertEqual(self.personal.read_bytes(), original) + transaction.transition('update-fixture', 'commit') + self.assertEqual(self.record()['state'], 'committed') + self.assertFalse((self.store.metadata / f'{self.point}.pin').exists()) + self.assertEqual(self.personal.read_bytes(), original) + + def test_failed_major_upgrade_finalization_rolls_back_before_logins(self): + self.begin() + transaction.transition('update-fixture', 'arm-upgrade', {'targetFedora': '45'}) + original_run = subprocess.run + finalizations = [] + + def execute_fixture(command, **options): + if command[0].endswith('cybexos-major-upgrade'): + finalizations.append(command) + return types.SimpleNamespace(returncode=1) + return original_run(command, **options) + + with patch.object(transaction.subprocess, 'run', side_effect=execute_fixture): + with patch.object(transaction, 'boot_id', return_value='new-major-boot'): + with self.assertRaises(SystemExit) as error: + transaction.recover() + self.assertEqual(error.exception.code, 75) + self.assertEqual(finalizations[0][1:], ['finalize', 'update-fixture']) + self.assertEqual(self.record()['state'], 'rolled-back') + self.assertTrue(self.record()['restartRequired']) + self.assertEqual(self.personal.read_text(), '{"personal": true}') + + def test_invalid_major_upgrade_transition_keeps_checkpoint_and_user_state(self): + self.begin() + before = self.record() + for metadata in (None, {}, {'targetFedora': 45}, {'targetFedora': '../../root'}): + with self.subTest(metadata=metadata): + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'arm-upgrade', metadata) + self.assertEqual(self.record(), before) + self.assertEqual(self.personal.read_text(), '{"personal": true}') + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'await-desktop') + self.assertTrue((self.store.metadata / f'{self.point}.pin').exists()) + + def test_snapshot_retention_never_prunes_an_active_transaction_point(self): + self.begin() + old_points = [self.point] + for index in range(1, 7): + point = f'20260930T1000{index:02d}Z-123' + old_points.append(point) + (self.store.roots / point).mkdir() + (self.store.boot / f'{point}.tar').write_bytes(b'fixture boot') + (self.store.metadata / f'{point}.meta').write_text('fixture\n') + deleted = [] + + def filesystem_fixture(command): + if command[0] == 'tar': + Path(command[command.index('--file') + 1]).write_bytes(b'new fixture boot') + elif command[:3] == ['btrfs', 'subvolume', 'snapshot']: + Path(command[-1]).mkdir() + elif command[:3] == ['btrfs', 'subvolume', 'delete']: + deleted.append(Path(command[-1]).name) + Path(command[-1]).rmdir() + elif command[:2] != ['sync', '-f']: + self.fail('Unexpected command escaped the filesystem fixture: ' + repr(command)) + + with patch.object(transaction.snapshot, 'run', side_effect=filesystem_fixture), \ + patch.object(transaction.snapshot.shutil, 'which', return_value='/fixture/tool'), \ + patch.object(transaction.snapshot, 'current_kernel', return_value='fixture-kernel'), \ + patch.object(transaction.snapshot, 'regenerate_quietly'), \ + patch.dict(os.environ, {'CYBEXOS_SNAPSHOT_ID': '20260930T100100Z-123'}): + transaction.snapshot.command_create(['fixture update']) + self.assertNotIn(self.point, deleted) + self.assertEqual(deleted, old_points[1:4]) + self.assertTrue((self.store.roots / self.point).is_dir()) + self.assertEqual(len(list(self.store.roots.iterdir())), transaction.snapshot.KEEP) + + def test_boot_recovery_aborts_unapplied_update_and_rolls_back_applied_one(self): + self.begin() + with patch.object(transaction, 'boot_id', return_value='next-boot'): + transaction.recover() + self.assertEqual(self.record()['state'], 'aborted') + self.assertEqual(self.restores, 0) + transaction.begin('second-update', self.point, os.getuid()) + transaction.transition('second-update', 'applying') + with patch.object(transaction, 'boot_id', return_value='next-boot'): + with self.assertRaises(SystemExit) as error: + transaction.recover() + self.assertEqual(error.exception.code, 75) + self.assertEqual(self.restores, 1) + + def test_login_dependency_never_recovers_an_update_from_the_current_boot(self): + self.begin() + for action, state in ((None, 'prepared'), ('applying', 'applying')): + if action: + transaction.transition('update-fixture', action) + transaction.recover() + self.assertEqual(self.record()['state'], state) + self.assertEqual(self.restores, 0) + + def test_refuses_competing_updates_and_illegal_transitions(self): + self.begin() + with self.assertRaises(transaction.snapshot.Failure): + transaction.begin('competing', self.point, os.getuid()) + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'commit') + transaction.transition('update-fixture', 'applying') + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'abort') + + def test_refuses_symlink_parent_and_preserves_checkpoint(self): + self.begin() + (self.home / '.local/bin').symlink_to(self.root / 'outside') + with self.assertRaises(transaction.snapshot.Failure): + transaction.rollback('update-fixture') + self.assertEqual(self.record()['state'], 'rollback-failed') + self.assertFalse((self.root / 'outside').exists()) + + def test_manifest_never_contains_personal_settings_or_registry(self): + paths = json.loads((ROOT / 'roles/base/files/cybexos-vendor-paths.json').read_text()) + for value in paths: + self.assertNotIn('.config/cybexos', value) + self.assertNotIn('.local/share/cybexos/plugins', value) + self.assertNotIn('.local/share/cybexos/themes', value) + self.assertNotIn('.service.d', value) + + def test_installed_transaction_payloads_share_the_source(self): + source = (ROOT / 'image/package').read_text() + tasks = (ROOT / 'roles/base/tasks/main.yml').read_text() + for name in ('cybexos-update-transaction', 'cybexos-update-recover', 'cybexos-vendor-paths.json'): + self.assertIn(name, source) + self.assertIn(name, tasks) + subprocess.run(['bash', '-n', str(ROOT / 'roles/base/files/cybexos-update-recover')], check=True) + + +class DesktopHealth(unittest.TestCase): + """Actual health state machine, with no host service or process operations.""" + + def setUp(self): + self.clock = 0.0 + self.calls = [] + self.ipc_calls = [] + self.ready_after = 0.0 + self.safe_mode = False + self.bad_journal = False + self.extra_process = False + self.restart_every = None + self.restart_at = None + self.ipc_gap = None + self.record = {'uid': 1000, 'desktopActive': True, 'failedUnits': []} + self.enterContext(patch.object(transaction, 'as_user', side_effect=lambda _uid, command: command)) + self.enterContext(patch.object(transaction, 'desktop_active', return_value=True)) + self.enterContext(patch.object(transaction, 'failed_units', return_value=[])) + self.enterContext(patch.object(transaction.pwd, 'getpwuid', return_value=types.SimpleNamespace( + pw_dir='/nonexistent-cybexos-health-fixture', pw_name='fixture'))) + self.enterContext(patch.object(transaction.time, 'monotonic', side_effect=lambda: self.clock)) + self.enterContext(patch.object(transaction.time, 'sleep', side_effect=self.advance)) + self.enterContext(patch.object(transaction, 'execute', side_effect=self.execute)) + + def advance(self, seconds): + self.clock += seconds + + def generation(self): + if self.restart_every: + return int(self.clock // self.restart_every) + return int(self.restart_at is not None and self.clock >= self.restart_at) + + def execute(self, command, **_options): + self.calls.append(command) + if command[:2] == ['rpm', '--verifydb']: + return '' + if command[:3] in (['systemctl', '--user', 'daemon-reload'], + ['systemctl', '--user', 'restart']): + return '' + if command[:3] == ['systemctl', '--user', 'show']: + if command[-2] == 'InvocationID': + return f'{self.generation() + 1:032x}' + if command[-2] == 'MainPID': + return str(3000 + self.generation()) + if command[0] == 'pgrep': + value = str(3000 + self.generation()) + return value + '\n9999' if self.extra_process else value + if command[-2:] == ['shell', 'status']: + return json.dumps({'safe': self.safe_mode}) + if command[-3:] == ['ipc', 'settings', 'status']: + self.ipc_calls.append(self.clock) + if self.clock < self.ready_after: + raise transaction.snapshot.Failure('IPC is not ready yet') + if self.ipc_gap and self.ipc_gap[0] <= self.clock < self.ipc_gap[1]: + raise transaction.snapshot.Failure('IPC disappeared during startup') + return '{"services":{}}' + if command[0] == 'journalctl': + return 'ReferenceError: delayed QML startup failed' if self.bad_journal else 'ready' + self.fail('Unexpected host operation in health fixture: ' + repr(command)) + + def test_delayed_qml_readiness_requires_two_stable_seconds_after_ipc(self): + self.ready_after = 1.5 + transaction.health(self.record) + self.assertGreaterEqual(self.clock, 3.5) + self.assertLess(self.clock, 4) + self.assertGreater(len(self.ipc_calls), 2) + self.assertEqual(sum(command[:3] == ['systemctl', '--user', 'restart'] + for command in self.calls), 1) + + def test_restart_during_validation_resets_pid_and_invocation_stability(self): + self.restart_at = 1.5 + transaction.health(self.record) + self.assertGreaterEqual(self.clock, 3.5) + journal = next(command for command in self.calls if command[0] == 'journalctl') + self.assertIn('_SYSTEMD_INVOCATION_ID=' + f'{2:032x}', journal) + + def test_lost_ipc_resets_the_stability_interval(self): + self.ipc_gap = (1, 1.5) + transaction.health(self.record) + self.assertGreaterEqual(self.clock, 3.5) + + def test_a_restart_loop_never_becomes_healthy(self): + self.restart_every = 1 + with self.assertRaisesRegex(transaction.snapshot.Failure, 'IPC-ready'): + transaction.health(self.record) + self.assertGreaterEqual(self.clock, 45) + self.assertFalse(any(command[0] == 'journalctl' for command in self.calls)) + + def test_fallback_shell_is_not_a_successful_update(self): + self.safe_mode = True + with self.assertRaisesRegex(transaction.snapshot.Failure, 'IPC-ready'): + transaction.health(self.record) + self.assertEqual(self.ipc_calls, []) + self.assertGreaterEqual(self.clock, 45) + + def test_a_second_qs_process_is_rejected(self): + self.extra_process = True + with self.assertRaisesRegex(transaction.snapshot.Failure, 'IPC-ready'): + transaction.health(self.record) + self.assertEqual(self.ipc_calls, []) + + def test_journal_failure_after_ready_ipc_prevents_commit(self): + self.bad_journal = True + with self.assertRaisesRegex(transaction.snapshot.Failure, 'QML errors'): + transaction.health(self.record) + self.assertGreaterEqual(self.clock, 2) + + def test_new_failed_units_stop_before_restarting_desktop(self): + self.record['failedUnits'] = ['existing-failure.service'] + with patch.object(transaction, 'failed_units', return_value=[ + 'existing-failure.service', 'new-failure.service']): + with self.assertRaisesRegex(transaction.snapshot.Failure, 'new-failure.service'): + transaction.health(self.record) + self.assertEqual(self.calls, [['rpm', '--verifydb']]) + + def test_prelogin_health_never_starts_or_queries_a_desktop(self): + transaction.health(self.record, desktop=False) + self.assertEqual(self.calls, [['rpm', '--verifydb']]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/verify-system b/tests/verify-system index dfa451ce..73c9f189 100755 --- a/tests/verify-system +++ b/tests/verify-system @@ -312,11 +312,10 @@ if command_exists firewall-cmd && unit_active firewalld.service; then ports=$(firewall-cmd --permanent --zone="$firewall_zone" --list-ports 2>/dev/null | tr ' ' '\n' | sort | tr '\n' ' ') expected=$(jq -r ' . as $root - | if $root.features.local_network_services then - $root.firewall_ports[] - | select((.feature == null) or ($root.features[.feature] == true)) - | "\(.port)/\(.protocol)" - else empty end + | $root.firewall_ports[] + | select(.always == true or $root.features.local_network_services) + | select((.feature == null) or ($root.features[.feature] == true)) + | "\(.port)/\(.protocol)" ' <<<"$inventory_json" | sort | tr '\n' ' ') [[ $ports == "$expected" ]] && pass 'firewall exposes only declared ports' || fail "firewall ports differ: $ports" services=$(firewall-cmd --permanent --zone="$firewall_zone" --list-services 2>/dev/null || true) @@ -351,11 +350,18 @@ else fi cmdline=$(/dev/null; then + warn 'Xe panel self-refresh workaround is configured and awaits reboot' +fi if command_exists mokutil; then mokutil --sb-state >/dev/null 2>&1 && pass 'Secure Boot state is readable' || warn 'Secure Boot state could not be read' fi