diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 00000000..b42bcdc8 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,189 @@ +name: ISO lifecycle qualification + +on: + workflow_dispatch: + inputs: + tag: + description: Reviewed release tag matching VERSION + required: true + type: string + baseline_iso: + description: Older supported ISO on the PXE host under /data/pxe/iso + required: true + type: string + workflow_call: + inputs: + tag: + required: true + type: string + baseline_iso: + required: true + type: string + secrets: + CYBEXOS_RPM_SIGNING_KEY: + required: false + +permissions: + contents: read + +concurrency: + group: cybexos-iso-release + cancel-in-progress: false + +jobs: + qualify: + # Debian 13 PXE host: only reviewed main/tag code, with no signing secret + # or desktop-release environment attached to this machine. + if: github.repository == 'DigitalPals/CybexOS' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + runs-on: cybexos-iso-${{ github.run_id }} + timeout-minutes: 360 + env: + RELEASE_TAG: ${{ inputs.tag }} + BASELINE_ISO: ${{ inputs.baseline_iso }} + PYTHONDONTWRITEBYTECODE: '1' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Prepare isolated release workspace + run: | + set -euo pipefail + work=$(mktemp -d "$RUNNER_TEMP/cybexos-release.XXXXXXXX") + printf 'RELEASE_WORK=%s\n' "$work" >> "$GITHUB_ENV" + test -r /data/pxe/README.md + test -n "$BASELINE_ISO" + systemctl is-active --quiet iventoy.service + image/build --preflight + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '24' + - name: Install isolated browser test driver + run: | + npm install --prefix "$RELEASE_WORK/browser" --no-audit --no-fund --ignore-scripts playwright-core@1.63.0 + printf 'NODE_PATH=%s\n' "$RELEASE_WORK/browser/node_modules" >> "$GITHUB_ENV" + - name: Build and qualify graphical installs, upgrade and recovery + run: | + image/release-gate --execute --output "$RELEASE_WORK/qualification" \ + --tag "$RELEASE_TAG" --baseline-iso "$BASELINE_ISO" + - name: Transfer qualified artifacts and reports to the hosted signing job + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cybexos-qualified-desktop + # The common root is qualification/. Do not upload VM disks, console + # logs, browser state, the builder cache, or the source checkout. + path: | + ${{ env.RELEASE_WORK }}/qualification/build/artifacts/ + ${{ env.RELEASE_WORK }}/qualification/*/qualification.json + ${{ env.RELEASE_WORK }}/qualification/release-gate.json + if-no-files-found: error + compression-level: 0 + retention-days: 2 + - name: Retain bounded qualification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cybexos-qualification-reports + path: | + ${{ env.RELEASE_WORK }}/qualification/release-gate.json + ${{ env.RELEASE_WORK }}/qualification/*/qualification.json + retention-days: 14 + if-no-files-found: ignore + - name: Remove task staging + if: always() + run: | + if [[ -n ${RELEASE_WORK:-} && $RELEASE_WORK == "$RUNNER_TEMP"/cybexos-release.* ]]; then + rm -rf -- "$RELEASE_WORK" + fi + + sign: + name: Sign qualified desktop artifacts on a hosted Fedora container + needs: qualify + runs-on: ubuntu-latest + environment: desktop-release + timeout-minutes: 120 + env: + RELEASE_TAG: ${{ inputs.tag }} + PYTHONDONTWRITEBYTECODE: '1' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Reserve space for raw and prepared desktop artifacts + run: | + set -euo pipefail + # This job owns its disposable GitHub-hosted VM. Remove only unused + # preinstalled SDKs; runner tools, Docker and our checkout stay intact. + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup + available=$(df --output=avail -B1 "$RUNNER_TEMP" | tail -n 1) + if (( available < 24 * 1024 * 1024 * 1024 )); then + echo 'Signing requires at least 24 GiB free for raw and prepared artifacts.' >&2 + exit 1 + fi + work=$(mktemp -d "$RUNNER_TEMP/cybexos-signing.XXXXXXXX") + printf 'RELEASE_WORK=%s\n' "$work" >> "$GITHUB_ENV" + - name: Prepare Fedora 44 RPM signing tools without private key access + run: | + docker build --tag cybexos-release-signing:local - <<'DOCKERFILE' + FROM fedora:44 + RUN dnf -y --setopt=install_weak_deps=False install python3 rpm rpm-sign createrepo_c gnupg2 tar gzip && dnf clean all + DOCKERFILE + - name: Download the exact qualified build and reports + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: cybexos-qualified-desktop + path: ${{ env.RELEASE_WORK }}/qualified + - name: Sign RPM and prepare verified release assets + env: + RELEASE_SIGNING_KEY: ${{ secrets.CYBEXOS_RPM_SIGNING_KEY }} + run: | + set -euo pipefail + test -n "$RELEASE_SIGNING_KEY" + # The key enters only this ephemeral hosted container. Its value is + # passed through the environment, never arguments or shell tracing. + docker run --rm --init --interactive \ + --name "cybexos-signing-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + --env RELEASE_SIGNING_KEY --env RELEASE_TAG --env GITHUB_REPOSITORY \ + --env PYTHONDONTWRITEBYTECODE=1 \ + --volume "$GITHUB_WORKSPACE:/source:ro" \ + --volume "$RELEASE_WORK:/release-work" \ + --workdir /source cybexos-release-signing:local bash -s <<'SIGN' + set -euo pipefail + keyhome=/release-work/signing + install -d -m 0700 "$keyhome" + trap 'gpgconf --homedir "$keyhome" --kill all; rm -rf -- "$keyhome"' EXIT + printf '%s' "$RELEASE_SIGNING_KEY" | gpg --homedir "$keyhome" --batch --import + unset RELEASE_SIGNING_KEY + fingerprint=$(python3 -c 'import json; print(json.load(open("image/channels/stable.json"))["fingerprint"])') + baseurl=$(python3 -c 'import json; print(json.load(open("image/channels/stable.json"))["baseurl"])') + image/release-repository /release-work/qualified/build/artifacts/cybexos-desktop-*.rpm \ + --output /release-work/signed --public-key image/channels/CYBEXOS-desktop.asc \ + --key "$fingerprint" --gnupghome "$keyhome" --baseurl "$baseurl" \ + --packages-baseurl "https://github.com/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG" + reports=() + for scenario in encrypted-us plain-us encrypted-nl plain-nl upgrade; do + reports+=(--qualification "/release-work/qualified/$scenario/qualification.json") + done + image/prepare-github-release --signed-repository /release-work/signed \ + --artifacts /release-work/qualified/build/artifacts \ + --output /release-work/publication --repository "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" --fingerprint "$fingerprint" "${reports[@]}" + # The container is root; artifacts must be readable by the hosted + # runner's upload action. Secret material is outside this directory. + chmod -R a+rX /release-work/publication + SIGN + - name: Retain qualified assets for the publishing job + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cybexos-desktop-release + path: ${{ env.RELEASE_WORK }}/publication/ + if-no-files-found: error + compression-level: 0 + retention-days: 2 + - name: Remove signing material and task artifacts + if: always() + run: | + docker rm --force "cybexos-signing-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >/dev/null 2>&1 || true + docker image rm cybexos-release-signing:local >/dev/null 2>&1 || true + if [[ -n ${RELEASE_WORK:-} && $RELEASE_WORK == "$RUNNER_TEMP"/cybexos-signing.* ]]; then + sudo rm -rf -- "$RELEASE_WORK" + fi diff --git a/.github/workflows/live-image.yml b/.github/workflows/live-image.yml index 54e25fc4..506e9b75 100644 --- a/.github/workflows/live-image.yml +++ b/.github/workflows/live-image.yml @@ -2,30 +2,10 @@ name: Live image integration on: pull_request: - paths: - - image/** - - roles/apps/** - - roles/base/** - - roles/desktop/** - - roles/boot/** - - roles/dotfiles/** - - assets/** - - VERSION - - inventory/group_vars/all.yml - - .github/workflows/live-image.yml push: - paths: - - image/** - - roles/apps/** - - roles/base/** - - roles/desktop/** - - roles/boot/** - - roles/dotfiles/** - - assets/** - - VERSION - - inventory/group_vars/all.yml - - .github/workflows/live-image.yml + branches: [main] workflow_dispatch: + workflow_call: permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02c21117..82098fba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,11 +15,32 @@ concurrency: cancel-in-progress: false jobs: + prerequisites: + name: Release prerequisites + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Require reviewed license, version and older ISO + env: + RELEASE_TAG: ${{ github.ref_name }} + BASELINE_ISO: ${{ vars.CYBEXOS_BASELINE_ISO }} + run: | + set -euo pipefail + test -s LICENSE || test -s LICENSE.md + test -n "$BASELINE_ISO" + version=${RELEASE_TAG#v} + scripts/semver validate "$version" + test "$(cat VERSION)" = "$version" + source: name: Source contract # The exact job that gates main: same packages, same verified COPR key. uses: ./.github/workflows/tests.yml + image-source: + name: Image source contract + uses: ./.github/workflows/live-image.yml + vm: name: Generic Fedora VM install runs-on: ubuntu-latest @@ -32,17 +53,33 @@ jobs: if test -e /dev/kvm; then sudo chmod a+rw /dev/kvm; fi - run: ./tests/fedora-vm-convergence + iso: + name: ISO install, upgrade and recovery + needs: [prerequisites, source, image-source, vm] + uses: ./.github/workflows/desktop-release.yml + with: + tag: ${{ github.ref_name }} + baseline_iso: ${{ vars.CYBEXOS_BASELINE_ISO }} + secrets: inherit + publish: name: Build, attest, and publish release asset - needs: [source, vm] + needs: [source, vm, iso] runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 60 permissions: contents: write id-token: write attestations: write steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Download qualified desktop assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: cybexos-desktop-release + path: dist/desktop-release + - name: Require a repository distribution license + run: test -s LICENSE || test -s LICENSE.md - name: Install release build dependencies run: | sudo apt-get update @@ -117,9 +154,10 @@ jobs: --generate-notes --title "CybexOS $version" "${prerelease[@]}" gh release upload "$RELEASE_TAG" \ "dist/cybexos-$version.tar.zst" \ - "dist/cybexos-$version.tar.zst.sigstore.jsonl" dist/SHA256SUMS + "dist/cybexos-$version.tar.zst.sigstore.jsonl" dist/SHA256SUMS \ + dist/desktop-release/assets/* gh release edit "$RELEASE_TAG" --draft=false - for attempt in $(seq 1 12); do + for _attempt in $(seq 1 12); do if test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq .immutable)" = true; then exit 0 fi @@ -127,3 +165,29 @@ jobs: done echo "GitHub did not make $RELEASE_TAG immutable after publication" >&2 exit 1 + + pages: + name: Publish signed desktop repository metadata + needs: publish + # Prerelease assets are downloadable but must never advance stable clients. + if: ${{ !contains(github.ref_name, '-') }} + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: cybexos-desktop-release + path: desktop-release + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa + with: + path: desktop-release/pages + - name: Deploy verified metadata after RPM assets exist + id: deploy + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..389ed546 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DigitalPals and CybexOS contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b6e3f063..dd3c069f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # CybexOS -CybexOS stands for **Cybex Opinionated System**. It is an opinionated Hyprland -and Quickshell desktop for Fedora Linux, installed and -kept current with Ansible. The core configuration is hardware-neutral. A -separate, precisely gated role preserves extra support for the 2026 Dell XPS -14 and 16. +CybexOS stands for **Cybex Opinionated System**. It is an opinionated Fedora +Linux desktop built around Hyprland and Quickshell. The primary installation +path is the bootable CybexOS ISO; a source-checkout installer remains available +for development and existing checkout deployments. The core configuration is +hardware-neutral. A separate, precisely gated role preserves extra support +for the 2026 Dell XPS 14 and 16. The current release target is Fedora 44 on x86_64. Fedora remains responsible for the kernel, drivers, SELinux, and base operating system. @@ -27,25 +28,46 @@ see [the operations guide](docs/operations.md#migrating-from-fedora-config). enabled by default - automatically detected XPS 2026 speaker, camera, haptic, fingerprint, backlight, firmware, and power support -- a persistent installer configuration, verifier, uninstaller, and verified - GitHub release updater +- persistent installer configuration and lifecycle tools for source-checkout + deployments, plus a verified source release updater - one release-scoped CybexOS skill discoverable by compatible coding agents for safe installed-system diagnosis and customization - a user-selectable default AI coding agent with terminal, launcher, and keyboard entry points -There is no desktop-preset selection: every installation gets the same core -Hyprland/Quickshell desktop. The installer asks only about the target machine, -security decisions, personal dotfiles, and application opt-outs. +Source-checkout installs have no desktop-preset selection: every installation +gets the same core Hyprland/Quickshell desktop. That installer asks about the +target machine, security decisions, personal dotfiles, and application opt-outs. -On an installed CybexOS desktop, apply only Omawrite and the managed file +On a source-checkout installation, apply only Omawrite and the managed file associations with the saved configuration: ```bash ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags omawrite,mime-defaults ``` -## Install +## Install from the ISO + +For a normal installation, boot the CybexOS ISO and follow the on-screen +installer. It installs the desktop RPM and offline application set, then +configures the target system without requiring a Git checkout or a network +connection for the desktop payload. The image's [installation guide](image/README.md) +describes disk requirements, encryption, first boot and recovery. Use the +release instructions in [docs/releasing.md](docs/releasing.md) for current +artifact availability and checksums. + +The installed desktop is delivered by `cybexos-desktop`; its packaged files +live under `/usr/share/cybexos`. The first-login account is configured by the +image installer, and subsequent package upgrades use the system update path. + +## Install from a source checkout + +This supported path is intended for development and existing checkout-based +deployments. For a regular new installation, use the ISO above. + +The commands below install from this repository with Ansible; they do not +install or update the ISO's `cybexos-desktop` RPM. Repository-based options and +the saved installer configuration described here apply to this checkout path. Start with Fedora 44 and a user that can run `sudo`: @@ -168,6 +190,16 @@ updates and uninstall because it is user data rather than Ansible policy. ## Update +On ISO installations, `cybex update` updates Fedora packages, Flatpaks, and +the desktop RPM when a signed update channel has been enrolled. The default +ISO configuration does not enable a desktop RPM channel. Inspect it with +`cybex update-channel status --json`; enabling one requires its reviewed public +configuration and full signing-key fingerprint. See the +[release guide](docs/releasing.md) for channel setup. + +On source-checkout installations, use the release updater described below. +These releases are separate from the ISO desktop RPM channel. + After the first install, use: ```bash @@ -203,15 +235,15 @@ Useful commands: | Command | Purpose | | --- | --- | -| `cybex update --check` | Check the configured GitHub channel | -| `cybex update --system-only` | Update Fedora and Flatpak only | +| `cybex update --check` (source checkout) | Check the configured GitHub channel | +| `cybex update --system-only` (source checkout) | Update Fedora and Flatpak only | | `cybex agent` | Launch or choose the per-user default AI coding agent | | `cybex dev status` | Show whether the verified or a development runtime is active | | `cybex plugin list` | Inspect personal widgets and API compatibility | | `cybex verify` | Check the installed system (`--source` opts into developer checks) | | `cybex doctor` | Alias for `verify` | -| `cybex configure` | Re-run the installer questions | -| `cybex uninstall` | Remove project-managed configuration; retain applications | +| `cybex configure` (source checkout) | Re-run the installer questions | +| `cybex uninstall` (source checkout) | Remove project-managed configuration; retain applications | Detailed updater status, logs, cancellation, Btrfs recovery, and advanced Ansible tags are documented in [the operations guide](docs/operations.md). @@ -253,7 +285,7 @@ Key documentation: - [Quickshell development notes](docs/quickshell-notes.md) > [!IMPORTANT] -> The repository does not yet contain a repository-wide software license. -> Select one before calling the project open source or publishing a public -> release. Undocumented bundled raster assets were removed from the release -> payload; `assets/PROVENANCE.json` enforces that boundary. +> The repository's original code and configuration are MIT-licensed. This +> license does not cover third-party packages, artwork, fonts, trademarks, or +> images. Audit those separate redistribution terms before publishing bundled +> release media; see [licensing and asset provenance](docs/licensing.md). diff --git a/agent-skills/cybexos/SKILL.md b/agent-skills/cybexos/SKILL.md index ee08bba0..7afa64ab 100644 --- a/agent-skills/cybexos/SKILL.md +++ b/agent-skills/cybexos/SKILL.md @@ -5,48 +5,52 @@ description: Operate and customize an installed CybexOS Hyprland/Quickshell work # CybexOS -Use this skill for the installed [CybexOS](https://github.com/DigitalPals/CybexOS) +Use this skill for an installed [CybexOS](https://github.com/DigitalPals/CybexOS) desktop (Cybex Opinionated System). Codex can invoke it as `$cybexos`; Claude -Code exposes the same skill as `/cybexos`. It also supports implicit -invocation through the description above. +Code exposes the same skill as `/cybexos`. Its description also supports +automatic selection. -## Establish the installation +## Identify the installation -Read the active release through -`~/.local/share/cybexos/current`. It is useful for diagnostics, command -source, schemas, and tests, but it is release-managed and read-only. +Check `rpm -q cybexos-desktop` first. On ISO installations, packaged vendor +files are under `/usr/share/cybexos`; the user's +`~/.local/share/cybexos/runtime` is a compatibility symlink to the packaged +runtime. These systems do not use `~/.local/share/cybexos/current`. -Before changing anything, choose the ownership layer: +If the desktop RPM is absent, check whether this is a source-checkout +installation. Its active release is selected by +`~/.local/share/cybexos/current`; inspect it read-only for diagnostics and +schemas. Do not assume a missing RPM means an incomplete installation. -- User shell preferences belong in - `~/.config/cybexos/shell.json`. Read +## Choose the ownership layer + +- User shell preferences belong in `~/.config/cybexos/shell.json`. Read [Quickshell settings](references/quickshell-settings.md) before editing it. - Personal bar widgets belong in user-owned plugin packages. Read [User widgets](references/user-widgets.md); use the versioned plugin API and - `cybex plugin` commands. Adding a personal widget does not require a - distro checkout, edits to built-in modules, or Ansible deployment. -- Personal Hyprland changes belong in - `~/.config/cybexos/hypr/user.lua`, loaded after vendor defaults. + `cybex plugin` commands. +- Personal Hyprland changes belong in `~/.config/cybexos/hypr/user.lua`. - Changes to distro defaults, built-in Quickshell code, services, packages, - and other release-managed behavior belong in a writable checkout. Read + and other vendor behavior belong in a writable source checkout. Read [Managed configuration](references/managed-configuration.md). For supported operator commands and desktop actions, read [Commands and desktop helpers](references/commands.md). -## Non-negotiable boundaries +## Boundaries -- Never edit `~/.local/share/cybexos/current` or anything below it. -- Never directly edit vendor files under - `~/.local/share/cybexos/runtime`. Diagnose them by reading; use - `~/.config/cybexos`, or make source changes in a writable checkout and - select it with `cybex dev enable`. +- Never edit packaged files under `/usr/share/cybexos` or files below the + source installation's `~/.local/share/cybexos/current` or + `~/.local/share/cybexos/runtime` directly. +- Do not treat `cybex repair` as a way to deploy checkout changes. On ISO + installations it reapplies the policy bundled with the installed RPM; make + vendor changes in source, rebuild/update the desktop RPM, and then use the + supported package update path. - Never add personal plugin IDs or settings to `shell.json`'s built-in `mods` - or `modOpts`; their normalizers only recognize built-in modules. Preserve - plugin packages, preferences, and state across updates and rollbacks. + or `modOpts`. Preserve plugin packages, preferences, and state across + updates and rollbacks. - Preserve unrelated checkout changes. Read every applicable `AGENTS.md` before modifying or testing a checkout. -- Do not clone a checkout unless the user agrees to the documented location. - Require explicit user intent before reconfiguration, updates, uninstall, cancellation, reboot, shutdown, reset, package removal, or another destructive operation. A diagnostic request authorizes inspection, not a diff --git a/agent-skills/cybexos/references/commands.md b/agent-skills/cybexos/references/commands.md index 42e660ad..002e2a51 100644 --- a/agent-skills/cybexos/references/commands.md +++ b/agent-skills/cybexos/references/commands.md @@ -1,23 +1,46 @@ # Commands and desktop helpers -Prefer installed commands over reconstructed shell pipelines. Read their -active source under `~/.local/share/cybexos/current` or run their help -before using an unfamiliar option. +Prefer installed commands over reconstructed shell pipelines. On RPM systems, +packaged command sources live under `/usr/share/cybexos`; on source-checkout +systems, inspect the active release under `~/.local/share/cybexos/current` or +run help before using an unfamiliar option. ## CybexOS -- `cybex version` reports the active release. -- `cybex verify` and `cybex doctor` run non-destructive - installed-system checks. Add `--source` only when repository/developer checks - are intended. -- `cybex update --check` checks the configured release channel. +- `cybex version` reports the active desktop release. +- `cybex doctor --json` runs read-only installed-system diagnostics. +- `cybex update-channel status --json` reports the configured RPM update + channel. This does not start an update. +- On source-checkout installations, `cybex update --check` checks the + configured release channel. ISO installations use + `cybex update-channel status --json` for read-only RPM channel status; + `cybex update --check` is not supported there. +- `sudo /usr/libexec/cybexos-reconcile --status` inspects pending versioned + account and machine policy reconciliation; `--retry` requests a retry. + RPM upgrades defer that work to `cybexos-reconcile.service` and its timer. +- On source-checkout installations, `cybex verify` and `cybex doctor` provide + installed checks; add `--source` only when repository/developer checks are + intended. - `cybexos-update-run status --json`, `log-dir`, and `read-log` inspect a - durable update without starting one. + durable source-checkout update without starting one. -An actual `cybex update`, `configure`, `install`, or `uninstall` needs -explicit user intent. So do `cybexos-update-run cancel`, reboot, -shutdown, and recovery/reset operations. Do not infer authorization from a -request to diagnose or check status. +A public RPM channel is enabled only after reviewing and verifying its public +configuration and key fingerprint. The explicit enrollment command is: + +```bash +sudo cybex update-channel enroll /path/to/public.json \ + --fingerprint FULL_OPENPGP_PRIMARY_FINGERPRINT +``` + +Add `--check` to validate the channel without enabling it. The default public +configuration path is `image/channels/stable.json` in the source tree. Enrollment +pins the public key and signed metadata before updates are enabled. Never use a +private signing key on the installed workstation. + +An actual `cybex update`, `configure`, `install`, or `uninstall` needs explicit +user intent. So do update cancellation, reboot, shutdown, and recovery/reset +operations. Do not infer authorization from a request to diagnose or check +status. ## Desktop actions @@ -26,20 +49,17 @@ corresponding action supplies intent; otherwise explain the command rather than launching an interactive selector or sending data. - `screenshot` selects a region, saves it under `~/Pictures/Screenshots`, and - copies it. `screenshot fullscreen` captures the focused monitor. A - notification offers Satty editing. + copies it. `screenshot fullscreen` captures the focused monitor. - `screen-record` toggles a selected-region recording. The first call starts; - the next verified call stops and saves under `~/Videos/Screen Recordings`. + the next call stops and saves under `~/Videos/Screen Recordings`. - `screen-ocr` selects a region and copies recognized English text to the clipboard. - `quickshell-reminder add MINUTES [MESSAGE]` schedules a persistent reminder. - Use `list --json`, `cancel ID`, or `clear` for management. Convert natural - language durations to a positive whole number of minutes and preserve the - user's message. -- `localsend` launches/passes arguments to the LocalSend Flatpak. - `localsend-share clipboard`, `localsend-share file [PATH...]`, and - `localsend-share folder [PATH...]` send through its headless interface; - omitted paths open an interactive chooser. + Use `list --json`, `cancel ID`, or `clear` for management. +- `localsend` launches the LocalSend Flatpak. `localsend-share clipboard`, + `localsend-share file [PATH...]`, and `localsend-share folder [PATH...]` + send through its headless interface; omitted paths open an interactive + chooser. Screen capture, recording, OCR, reminders, and LocalSend are user-visible or externally consequential. Report cancellation or command failure accurately; diff --git a/agent-skills/cybexos/references/managed-configuration.md b/agent-skills/cybexos/references/managed-configuration.md index 2aa9f06a..4426b6ff 100644 --- a/agent-skills/cybexos/references/managed-configuration.md +++ b/agent-skills/cybexos/references/managed-configuration.md @@ -1,10 +1,18 @@ # Managed Hyprland and Quickshell changes Use this guide for persistent behavior owned by CybexOS: Hyprland, -Quickshell source, services, packages, launchers, or Ansible policy. Personal -widgets use [the user widget API](user-widgets.md), and personal Hyprland -overrides use `~/.config/cybexos/hypr/user.lua`. Those changes do not need -a distro fork. Do not edit deployed vendor copies or the active release tree. +Quickshell source, services, packages, launchers, or provisioning policy. +Personal widgets use [the user widget API](user-widgets.md), and personal +Hyprland overrides use `~/.config/cybexos/hypr/user.lua`. Those changes do +not need a distro fork. Never edit deployed vendor copies. + +ISO installations receive vendor files in the `cybexos-desktop` RPM under +`/usr/share/cybexos`; `~/.local/share/cybexos/runtime` points to its runtime. +There is no `~/.local/share/cybexos/current` release link on this installation +type. Persistent vendor changes for an ISO installation must be made in a +source checkout, included in a rebuilt desktop RPM, and delivered through the +RPM update channel. `cybex repair` reapplies policy bundled in the installed +RPM; it does not deploy files from a checkout. ## Find a writable checkout @@ -23,30 +31,33 @@ a distro fork. Do not edit deployed vendor copies or the active release tree. 4. Read the repository root `AGENTS.md` and any nearer `AGENTS.md` files before acting. -Use the active release read-only when no source change is needed. It contains -the exact deployed command and schema sources and is safer evidence than -memory. +Use the packaged source under `/usr/share/cybexos` read-only to inspect an ISO +installation. On a source-checkout installation, use its active release +read-only when no source change is needed. These are different deployment +paths; do not run checkout Ansible against an ISO installation as a way to +apply RPM-managed files. ## Change and deploy Keep the edit in the smallest managed source file. Check the worktree before and after, and do not reformat, delete, stage, or restore unrelated changes. -Run the repository gate before deployment: +Run the repository gate before a source-checkout deployment: ```bash ./tests/run ``` -Preview the machine change with the saved installer contract when practical: +For source-checkout installations, preview the machine change with the saved +installer contract when practical: ```bash ansible-playbook site.yml -e @/etc/cybexos/config.yml --check --diff ``` -Deploy through Ansible, choosing only a documented narrow tag when its -prerequisites are already present. Hyprland and Quickshell are normally in the -`desktop` role: +Deploy to a source-checkout installation through Ansible, choosing only a +documented narrow tag when its prerequisites are already present. Hyprland and +Quickshell are normally in the `desktop` role: ```bash ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags desktop diff --git a/docs/architecture/ownership.md b/docs/architecture/ownership.md index 0b07b8b3..dff53810 100644 --- a/docs/architecture/ownership.md +++ b/docs/architecture/ownership.md @@ -1,40 +1,44 @@ # CybexOS ownership boundary -CybexOS updates replace vendor runtime and integration files. They do -not merge into user customization trees. This is the machine-enforced boundary -for the transitional, pre-RPM layout. +CybexOS has two supported installation paths. ISO installations receive vendor +files through the `cybexos-desktop` RPM, installed under +`/usr/share/cybexos`; source-checkout installations use versioned releases and +may opt into a writable development checkout. In both paths, user preferences +and personal packages remain in user-owned locations. | Owner | Path | Update behavior | | --- | --- | --- | -| Vendor | `~/.local/share/cybexos/runtime/quickshell/` | Reconciled exactly from a verified release | -| Vendor | `~/.local/share/cybexos/runtime/hypr/` | Reconciled from rendered defaults in a verified release | -| Vendor | `~/.local/share/cybexos/releases/` and `current` | Staged and atomically selected by the release updater | -| User | `~/.config/cybexos/shell.json` | Read and written by the shell; never written by Ansible after a one-time, non-overwriting legacy copy | +| Vendor, ISO | `/usr/share/cybexos/` | Owned by `cybexos-desktop`; replaced by RPM upgrades | +| Vendor, ISO | `~/.local/share/cybexos/runtime` | Compatibility symlink to `/usr/share/cybexos/runtime` | +| Vendor, source checkout | `~/.local/share/cybexos/runtime/` | Reconciled from the selected verified release | +| Vendor, source checkout | `~/.local/share/cybexos/releases/` and `current` | Staged and atomically selected by the source updater | +| User | `~/.config/cybexos/shell.json` | Shell preferences; preserved by package/release updates | | User | `~/.config/cybexos/hypr/` | Optional `user.lua`, `hypridle.conf`, and `hyprlock.conf` overrides | -| User | `~/.config/cybexos/displays.json` | Settings → Displays choices per physical monitor; written only by that page after a confirmed trial, read by the vendor `displays.lua`, never written by Ansible | -| User | `~/.local/share/cybexos/themes/` | Reserved user theme packages; never reconciled or pruned | -| User | `~/.local/share/cybexos/plugins/` | API 1 widget packages; never reconciled or pruned | -| User | `~/.config/cybexos/plugins.json` | Separate widget enablement and preferences; never written by Ansible | +| User | `~/.config/cybexos/displays.json` | Settings → Displays choices per physical monitor | +| User | `~/.local/share/cybexos/themes/` | User theme packages; not reconciled or pruned | +| User | `~/.local/share/cybexos/plugins/` | API 1 widget packages; not reconciled or pruned | +| User | `~/.config/cybexos/plugins.json` | Widget enablement and preferences; not written by Ansible | | User | `~/.local/share/cybexos/plugin-data/` | Persistent widget data; retained on update and uninstall | | State | `~/.local/state/cybexos/` | Health, update, migration, and shell runtime state | +| State, machine | `/var/lib/cybexos/` | Reconciliation state, backups, and hardware setup status | -The session always starts Hyprland with the vendor entry point. Vendor modules -load first, then the saved Settings → Displays choices; -`~/.config/cybexos/hypr/user.lua`, when present, loads last. Bindings it -adds with a `Group: Label` description appear in the Super+K cheatsheet -([keyboard shortcuts](../keyboard-shortcuts.md)). -The idle and lock services prefer their same-named user configuration files -and otherwise use vendor defaults. A bad user override may break that component -but is never silently replaced by an update. Without a user `hypridle.conf`, -the idle service applies the timeouts from Settings → System → Idle: the -runtime resolver renders them from `shell.json` into -`$XDG_RUNTIME_DIR/cybexos/hypridle.conf` at each start (falling back to -the vendor file), and the shell restarts `hypridle.service` after a change is -saved. Without a user `hyprlock.conf`, the lock uses the lock screen the -system theme renders in the shell's colours, font and wallpaper -(`~/.local/state/cybexos/theme/hyprlock.conf`, state rather than -configuration) when that file is present and carries the renderer's header, -and the vendor file otherwise (docs/system-theme.md). +On the RPM path, package upgrades defer versioned account and machine policy +updates to `cybexos-reconcile.service` and its timer. Reconciliation preserves +existing edits by backing up files before updating them; inspect status with +`sudo /usr/libexec/cybexos-reconcile --status` and request a retry with +`sudo /usr/libexec/cybexos-reconcile --retry`. The user initialization payload +uses versioned defaults, separately from unversioned tool-seed data. Hardware +setup status is recorded at `/var/lib/cybexos/hardware-status.json` as +`pending`, `completed`, or `failed`; a pending camera setup resumes after a +same-kernel reboot. The welcome window displays this status. + +On the source-checkout path, the session starts Hyprland with the vendor entry +point. Vendor modules load first, then saved Settings → Displays choices, and +`~/.config/cybexos/hypr/user.lua`, when present, loads last. Bindings it adds +with a `Group: Label` description appear in the Super+K cheatsheet +([keyboard shortcuts](../keyboard-shortcuts.md)). The idle and lock services +prefer their same-named user configuration files and otherwise use vendor +defaults. User overrides are not silently replaced by release updates. Quickshell starts with an explicit `qs -p` path. The legacy `~/.config/quickshell` and `~/.config/hypr` trees are not runtime inputs after @@ -46,42 +50,45 @@ translated automatically. ## Development source switch -`cybex dev enable /absolute/path/to/checkout` selects live Quickshell -sources and static Hyprland modules from a validated, user-owned Git checkout. -Rendered machine modules continue to come from the 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. +`cybex dev enable /absolute/path/to/checkout` selects live Quickshell sources +and static Hyprland modules from a validated, user-owned Git checkout on the +source-checkout path. Rendered machine modules continue to come from the +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. -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 checkout or user-owned paths. +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 +checkout or user-owned paths. ISO installations use RPM updates instead of this +release switch. ## Enforcement rules -- Deployment may prune only a vendor-owned runtime root. +- ISO package upgrades own files under `/usr/share/cybexos`; do not edit those + deployed vendor files in place. +- Source deployment may prune only its vendor-owned runtime root. - Normal convergence must not copy, template, link, or remove children below - the user-owned roots in the table, except to create an absent directory or - perform an explicitly non-overwriting legacy migration. + user-owned roots, except to create an absent directory or perform an + explicitly non-overwriting legacy migration. - Uninstall removes vendor runtime and integration artifacts, not user-owned Quickshell, Hyprland, theme, or plugin trees. -- The ownership-preservation test runs in the source and release gates and - simulates an N to N+1 update with byte-for-byte user sentinels. +- Ownership-preservation checks simulate updates with user data sentinels. ## Customization compatibility File ownership and runtime compatibility are separate requirements. The [user widget API](user-widgets.md) gives personal QML a versioned interface, independent preferences, and real-engine compatibility fixtures. Agents use -that interface for personal widgets; a distro checkout is for changing the +that interface for personal widgets; a source checkout is for changing the vendor implementation. A future refactor must retain supported API adapters. The distro-wide target is vendor defaults followed by explicit user choices. -New defaults apply to settings without an explicit choice; they must not -erase user choices even when those choices equal an old default. Migration -must 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. +New defaults apply to settings without an explicit choice; they must not erase +user choices even when those choices equal an old default. Migration must +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: @@ -96,7 +103,7 @@ That target is not yet enforced for every application. Remaining work: - 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 tests beyond file sentinels: verify settings behavior, an +- 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. diff --git a/docs/iso-releases.md b/docs/iso-releases.md new file mode 100644 index 00000000..620dc540 --- /dev/null +++ b/docs/iso-releases.md @@ -0,0 +1,206 @@ +# ISO and desktop RPM releases + +CybexOS ISO releases and source archive releases share a version tag but have +separate build gates and artifacts. A trusted Debian 13 PXE runner builds the +ISO and desktop RPM and exercises the installer in disposable guests. It +transfers only the build artifacts and qualification reports. A separate +GitHub-hosted signing job prepares the signed desktop assets; private signing +material never goes to the PXE host. Signed RPM repository metadata is deployed +to GitHub Pages after the GitHub Release has been published and confirmed +immutable. + +The desktop update endpoint is +[`https://digitalpals.github.io/CybexOS/44/x86_64`](https://digitalpals.github.io/CybexOS/44/x86_64). +Pages serves `update-channel.json`, the public key, signed release and +`repodata`; the RPMs themselves are assets of the matching immutable GitHub +Release. The channel configuration is [stable.json](../image/channels/stable.json), +which pins signing fingerprint +`16C60642B7278AECE3A933C354220839FDF7099E`. + +## Repository and runner setup + +The GitHub repository already has immutable releases enabled and GitHub Pages +configured. The Pages site is empty until a release is published. The +`desktop-release` Actions environment and its `CYBEXOS_RPM_SIGNING_KEY` secret +are configured. The secret contains the RPM signing subkey and is used only by +the GitHub-hosted Fedora 44 signing job. The PXE runner receives no private +signing material. The primary private key remains in a protected local +keyring; move it to offline custody before public release. Never print or copy +signing material into the checkout or expose it in workflow arguments or +logs. + +Before the first public release: + +- The repository includes its MIT license. This covers repository code; it + does not settle redistribution rights for third-party software and bundled + assets. Complete that audit before public distribution; see + [licensing and asset provenance](licensing.md). +- Use the trusted Debian 13 x86_64 PXE operator account with `/dev/kvm`, + at least 180 GiB staging space and 24 GiB available RAM, the + `image/build --preflight` dependencies, a Chromium browser, write access to + `/data/pxe/iso`, and an active `iventoy.service`. The workflow provides Node + 24 and isolated `playwright-core`. The operator also needs authenticated + `gh` access with permission to manage this repository's runners, Python + 3.12 or newer, the GitHub runner's native .NET dependencies, and a reachable + systemd user manager. Keep signing material on GitHub's hosted signing job. +- Repository variable `CYBEXOS_BASELINE_ISO` is configured as + `/data/pxe/iso/CybexOS-Live-44-20260926T055804Z-dbdd33d6.iso`. Keep this older + supported ISO and its matching `.sha256` sidecar in place. The qualification + job requires a regular, checksum-verified ISO under `/data/pxe/iso`. +- Start an ephemeral runner only for the reviewed queued run below, and pass all release gates. The qualification + has to pass on the exact release build; configuring Pages, the baseline + variable, and signing environment alone does not make a public desktop + channel available. + +The PXE machine does not keep a public-repository runner listening. Once a +reviewed `release.yml` or `desktop-release.yml` run has its qualification job +queued, use a clean checkout whose `HEAD` exactly matches that run. From the +unprivileged PXE operator's authenticated session: + +```bash +gh auth status --hostname github.com +systemctl --user show --property=Version --value +run_id=REVIEWED_RUN_ID +./image/release-runner --run-id "$run_id" +./image/release-runner --run-id "$run_id" --execute \ + --work-root /data/cybexos-runners +``` + +The first helper command only validates the API run, job, source commit and +checkout. `--execute` downloads the current Linux x64 runner with the SHA-256 +pin supplied by GitHub's API and registers one ephemeral runner. Its only +label is `cybexos-iso-RUN_ID`; it has no generic `self-hosted`, `linux`, `x64`, +or `cybexos-iso` labels. The workflow requests that exact run-specific label. +The helper refuses pull requests, other source repositories, non-release +workflows, refs outside `main` or version tags, dirty or mismatched checkouts, +an existing runner for the run, or another queued job targeting its label. +No runner has been registered by this setup; registration is an explicit +operator action when a reviewed job is queued. + +The listener runs in a transient user systemd service with a seven-hour limit +(`--timeout` accepts 60 seconds through eight hours). Cancellation allows +120 seconds for job cleanup, then systemd terminates the complete service +cgroup, including build and VM processes that created separate sessions. +The helper stops and verifies this exact unit before unregistering the runner +and removing its unique task directory. If it cannot confirm the unit stopped, +it reports and retains that directory for diagnosis instead of deleting live +VM files. A failed API cleanup reports the exact registration to remove. +The registration token stays out of command arguments and logs, and the runner +gets an isolated home and environment without the operator's GitHub tokens. + +This is a trusted-code runner, not a sandbox. Do not approve or queue untrusted +workflows while it is active: someone allowed to execute a workflow could +intentionally request the known run-specific label. Signing keys are available +only to the separate GitHub-hosted `sign` job and never to this PXE listener. + +The release checks the source contract, image-source contract, and a +twice-converged generic Fedora VM before calling +[desktop-release.yml](../.github/workflows/desktop-release.yml) on the trusted +PXE runner. Its `qualify` job builds the ISO with the public stable channel, +publishes the completed testing ISO and checksum to iVentoy, and runs five +QEMU guests with isolated disposable disks. It uploads only the raw build +artifacts and bounded qualification reports as `cybexos-qualified-desktop` for +two days. The `qualify` job also retains compact qualification evidence for +fourteen days. The separate GitHub-hosted `sign` job downloads that exact +artifact and produces `cybexos-desktop-release`, retaining the prepared assets +for two days. The signing job needs a +Docker-capable GitHub-hosted runner with at least 24 GiB free staging space. + +| Scenario | Encryption | Keyboard and locale | Timezone | +| --- | --- | --- | --- | +| `encrypted-us` | LUKS | US / `en_US.UTF-8` | UTC | +| `plain-us` | Off | US / `en_US.UTF-8` | UTC | +| `encrypted-nl` | LUKS | Dutch / `nl_NL.UTF-8` | Europe/Amsterdam | +| `plain-nl` | Off | Dutch / `nl_NL.UTF-8` | Europe/Amsterdam | + +Each of the four fresh scenarios boots the exact candidate ISO and completes +the graphical installer. A fifth guest uses the older baseline ISO, creates a +recovery point, applies the exact candidate desktop RPM, then boots and restores +that pre-upgrade point and verifies that the baseline RPM is active again. This +older-image upgrade and recovery qualification is separate from the four +fresh-install scenarios. + +A release is rejected unless qualification reports identify the SHA-256 of the +exact ISO and candidate RPM and all five scenarios pass. Reports are retained +as bounded workflow artifacts; the desktop assets are retained briefly for the +publishing job. Do not treat source fixtures, a local RPM build, or an earlier +ISO boot as a substitute for these release gates. + +## Published assets and ISO reconstruction + +The publishing job creates the tagged source archive and its offline-verifiable +provenance bundle, then uploads those with the desktop release assets and +`SHA256SUMS` to a draft GitHub Release. It publishes the draft after uploading +all assets, then polls until GitHub marks the release immutable; failure to +confirm immutability fails the workflow after publication. Only after this job +passes does a stable tag deploy signed metadata to Pages. A prerelease tag +publishes downloadable assets but does not update the stable Pages repository. +The ISO is split into files below GitHub's per-asset size limit. `assets/` also +contains the part manifest, original ISO SHA-256 file, reconstruction script, +qualification reports, signed RPMs, and `desktop-SHA256SUMS`. + +Download `desktop-SHA256SUMS` and every file it names into one directory, then +verify and reconstruct without replacing an existing output: + +```bash +sha256sum -c desktop-SHA256SUMS +python3 reconstruct-iso.py CybexOS-Live-44-BUILD-ID.iso.parts.json +sha256sum -c CybexOS-Live-44-BUILD-ID.iso.sha256 +``` + +The reconstruction tool checks each ordered part against the JSON manifest, +then checks the complete ISO size and SHA-256 before publishing the output. +It refuses to overwrite an existing ISO. Keep the manifest, parts, and helper +in the same directory. Check the signing fingerprint independently before +trusting the RPM or channel metadata. + +After the immutable GitHub Release is available, the Pages job deploys the +signed metadata snapshot. Metadata points each package URL at that release's +RPM asset, so Pages never needs to host the large RPM. Publication is ordered: +the Pages deployment waits until the immutable Release and RPM assets exist. +Until both jobs complete successfully, the channel is not ready for enrollment. + +## Enroll the installed RPM channel + +An ISO built without `--update-channel` bundles a disabled channel. The release +gate explicitly enables the pinned stable channel. Check the installed system +with read-only diagnostics; channel status describes local configuration and +does not test remote availability: + +```bash +cybex doctor --json +cybex update-channel status --json +``` + +For enrollment, obtain `update-channel.json` and `CYBEXOS-desktop.asc` from the +same published release's verified assets, keeping them together in one +folder. Review the URL and compare the configuration's full fingerprint with +the independently trusted value above. Validate connectivity and signatures +without changing the system: + +```bash +cybex update-channel enroll /path/to/release/update-channel.json \ + --fingerprint 16C60642B7278AECE3A933C354220839FDF7099E --check +``` + +When the check succeeds, explicitly enable the repository: + +```bash +sudo cybex update-channel enroll /path/to/release/update-channel.json \ + --fingerprint 16C60642B7278AECE3A933C354220839FDF7099E +cybex update-channel status --json +``` + +Enrollment verifies the public key fingerprint and signed repository metadata, +then pins the public key and repository configuration locally. It does not +install an update. The ordinary system package update path can install the +signed desktop RPM once the channel is ready. `cybex update --check` is a +source-checkout command and is not supported for ISO installations. Key +rotation requires a separately reviewed migration; do not enroll a different +fingerprint as a routine update. + +The current GitHub repository has not published its first public release and +the Pages endpoint is still empty. The repository has selected the MIT +License for its code, but the bundled software and asset redistribution audit +still applies. The explicitly started ephemeral runner and complete release workflow must +pass before signed desktop metadata is available to users. diff --git a/docs/licensing.md b/docs/licensing.md index 93feb2d8..514fd732 100644 --- a/docs/licensing.md +++ b/docs/licensing.md @@ -1,17 +1,12 @@ # Licensing and asset provenance -There is currently no repository-root `LICENSE` or `COPYING` file. Repository -visibility and a Git commit history do not themselves grant permission to copy, -modify, or redistribute the original configuration code. This document records -that boundary; it does not choose a software license on the owner's behalf. +The repository's original code and configuration are licensed under the MIT +License; see the repository-root `LICENSE`. That grant applies to CybexOS +copyrighted code and does not replace licenses or permissions for third-party +software, assets, product names, or marks included in a source archive, ISO, or +RPM. -## Repository code and configuration - -The owner must choose the intended terms, confirm that every contributor can -license their contribution on those terms, and add the corresponding canonical -license text at the repository root. If different directories need different -terms, add unambiguous per-directory notices and a root summary. Until then, -downstream users should not infer an open-source license. +## Third-party software and branding Files copied or downloaded from other projects remain under their upstream terms. In particular: @@ -23,10 +18,13 @@ terms. In particular: - the Cybex role checks out a pinned upstream artwork revision and then overlays repository-local theme files; and - product names, logos, and brand SVGs may also be subject to trademark rules, - independently of any software license eventually selected here. + independently of the MIT License. -A repository-wide software license must not be presented as relicensing those -third-party materials. +A public source archive, desktop RPM, or ISO bundles more than original CybexOS +code. Complete and document the software and asset redistribution audit for the +actual release payload before public distribution. The MIT License does not +itself grant rights to redistribute third-party packages, artwork, fonts, +wallpapers, or trademarks. ## Repository assets diff --git a/docs/releasing.md b/docs/releasing.md index 542b5f5d..c486c8d9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,20 +1,47 @@ # Publishing CybexOS releases -Public updates are built from semantic-version Git tags by -`.github/workflows/release.yml`. The workflow will not publish unless the full -source contract and a twice-converged generic Fedora VM both pass. +CybexOS has two release surfaces. Source-checkout releases publish a +versioned source archive; ISO releases publish a signed desktop RPM, bootable +ISO, and RPM update metadata. Both use a semantic-version Git tag and share +the repository source contract and generic Fedora VM gate. The release also +runs the image-source contract, then requires trusted PXE-host qualification +before publication. See [ISO and desktop RPM releases](iso-releases.md) for +runner setup, signing, the installer matrix, reconstruction, and channel +enrollment. -## One-time repository setup +The source archive is published by `.github/workflows/release.yml` after all +required jobs pass. Its prerequisite job checks the tag against `VERSION`, +requires a non-empty `CYBEXOS_BASELINE_ISO` repository variable and a root +license file, and validates semantic versioning. The ISO job additionally +validates that the baseline file and checksum sidecar exist on the PXE host. -1. Enable immutable releases in the GitHub repository settings. -2. Keep Actions permitted to create attestations and write release contents; - the workflow grants only those job-level permissions. -3. Protect the default branch and require the source and generic-VM checks. +## Source archive setup + +1. Immutable GitHub Releases are enabled for the repository. Keep Actions + permitted to create attestations and write release contents; the workflow + grants only those job-level permissions. +2. The `desktop-release` environment and its `CYBEXOS_RPM_SIGNING_KEY` secret + provide signing access only to the GitHub-hosted signing job. The PXE + qualification runner never receives that secret. The `github-pages` + environment deploys stable repository metadata after release publication. +3. Keep `CYBEXOS_BASELINE_ISO` set to the verified older ISO and configure the + on-demand trusted self-hosted PXE runner as described in + [the ISO release guide](iso-releases.md). +4. Branch protection requires pull requests, up-to-date branches, and the + GitHub Actions checks `Fedora 44 source contract` and `Image tooling, + installer fixtures and account isolation`. Admins are subject to the rule; + force pushes and branch deletion are disabled. The generic Fedora VM is a + release-tag gate, not a required branch check. The updater refuses a release when GitHub reports `immutable: false`, even if the archive checksum is otherwise correct. -## Release checklist +GitHub Pages is configured at the desktop update URL but is currently empty; +the first stable ISO release workflow will deploy signed metadata there. +Prerelease tags publish downloadable assets but do not update the stable Pages +repository. + +## Source archive release checklist 1. Review `release-manifest.json`, `VERSION`, the Fedora release, configuration schema, minimum updater version, and all dependency pins. @@ -45,7 +72,7 @@ deployed from the failed candidate stay in place; the run records `~/.local/share/cybexos/current/install` restores the previous release's files in the meantime. -## What the workflow publishes +## What the source workflow publishes The release contains a versioned source archive, its provenance bundle `cybexos-VERSION.tar.zst.sigstore.jsonl`, and `SHA256SUMS`. The checksum @@ -71,6 +98,9 @@ SHA-256 digest form the updater trust boundary. Updating needs no GitHub login, and a release without its bundle is refused. Files copied from an arbitrary branch or mutable URL are not accepted as updates. -Until the first release is published, `cybex update --check` reports that no -release exists on the channel, and updates install Fedora and Flatpak -packages only. That is expected, not a failure. +Until a source archive release is published, source-checkout +`cybex update --check` reports that no release exists on its channel. On ISO +installations, use `cybex update-channel status --json`; that command's channel +status is separate from source archive discovery. See [ISO and desktop RPM +releases](iso-releases.md) for channel enrollment after the first stable +desktop release has been published. diff --git a/docs/xps-2026-hardware.md b/docs/xps-2026-hardware.md index 0f886467..19887124 100644 --- a/docs/xps-2026-hardware.md +++ b/docs/xps-2026-hardware.md @@ -109,11 +109,12 @@ OV08X40 sensor driver, and `intel/ipu/ipu7ptl_fw.bin`. Those remain owned by the normal Fedora kernel and `intel-vsc-firmware` packages. Verification rejects an out-of-tree replacement for either `intel_ipu7` or `intel_ipu7_isys`. -Two missing hardware companions use DKMS: +Missing hardware companions use DKMS: - Intel IPU7 PSYS, compiled in PSYS-only mode against Fedora's stock core ABI. -- Intel CVS, which acquires Panther Lake's camera power/ownership path before - the OV08X40 sensor probes. +- Intel CVS only on kernels which do not supply it. Fedora's native CVS is + retained when present; its runtime PM and media bridge manage camera + ownership. The legacy CVS DKMS build refuses to shadow a native module. RPM Fusion's `akmod-v4l2loopback` supplies the third out-of-tree module. Intel's HAL, redistributable IPU75XA libraries, and `icamerasrc` are built into the @@ -142,8 +143,19 @@ BTF with `pahole` and compares the private `ipu7_device`, `ipu7_bus_device`, and tied to an exact kernel version. If Fedora changes that internal ABI, it emits `IPU7_STOCK_ABI_CHANGED` and refuses the optional camera build/start while leaving the stock modules and normal kernel update path untouched. -It also refuses DKMS if a future Fedora kernel starts shipping PSYS or CVS -itself, preventing the optional bundle from shadowing a new native companion. +The guard also covers callback, shared queue, and firmware boot structures. +The reviewed Fedora 7.2 layout accounts for the public `auxiliary_device` +growing by eight bytes: the PSYS build uses the target kernel headers and the +private structure offsets are checked as a complete signature. Unrecognized +layouts still fail closed. It refuses PSYS DKMS if Fedora starts shipping +PSYS itself. Native CVS does not require replacement or an extra reboot when +installing PSYS for the first time. + +With native CVS the graph is `OV08X40 -> Intel CVS -> IPU7 CSI2`. A narrow HAL +patch resolves the sensor's actual I2C address across this bridge and programs +its sink/source formats and links. Older direct sensor-to-CSI configurations +remain supported. Merely registering the sensor does not prove frame capture; +validate processed frames after installation. The complete build-input hash is embedded in the RPM release, its installed manifest, and both DKMS package versions. Consequently, changing a source pin, @@ -236,6 +248,12 @@ smoke test explicitly: XPS_CAMERA_FRAME_TEST=1 /usr/local/libexec/xps-ipu7-camera-check ``` +The frame check skips the relay's initial splash buffers and verifies three +complete, changing images in memory; it saves no images. An identical/covered +scene can make this proof inconclusive. The relay disables its producer's +last-sample retention so GStreamer mmap consumers copy and immediately requeue +loopback buffers across the splash-to-camera transition. + The Fedora package's unused generic `icamerasrc` generator trigger is removed on this hardware; the role's `ipu7` relay is the only camera relay instance. The suspend hook stops only the userspace relay before sleep and restarts it diff --git a/image/Containerfile.tests b/image/Containerfile.tests index 2005b0f5..8847325f 100644 --- a/image/Containerfile.tests +++ b/image/Containerfile.tests @@ -1,7 +1,7 @@ FROM fedora:44 RUN dnf install -y python3-pyside6 python3-jinja2 python3-pyyaml python3-gobject-base glib2 \ ShellCheck pykickstart qt6-qtdeclarative-devel desktop-file-utils \ - nodejs24 gnupg2 git ripgrep ansible-core \ + nodejs24 gnupg2 git ripgrep ansible-core rpm-build rpm-sign createrepo_c \ && dnf clean all ENV QT_QPA_PLATFORM=offscreen WORKDIR /source diff --git a/image/INSTALLER.md b/image/INSTALLER.md index c7996ab6..105203f3 100644 --- a/image/INSTALLER.md +++ b/image/INSTALLER.md @@ -84,6 +84,10 @@ These are the Fedora 44 versions used as the source reference. validation task, then review. CybexOS sets both selected disks and `DrivesToClear` to the one chosen disk. It obtains candidate disks from `GetUsableDisks` and excludes protected/non-disk devices. +- [Storage scan interface](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/storage/storage_interface.py) + provides `ScanDevicesWithTask` for an explicit rescan. A rescan invalidates + the prior review token. Existing partition details come from read-only + `lsblk`; Anaconda remains the authority for disk selection. - [Disk initialization interface](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/storage/disk_initialization/initialization_interface.py) defines the clearing scope. [PartitioningRequest](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/common/structures/partitioning.py) supplies Btrfs scheme `1`, LUKS2, and encryption policy. @@ -112,7 +116,9 @@ guards or invoke a real installation. | Command | Input | Result | | --- | --- | --- | -| `inventory` | `{}` | Available disks, layouts, locales, timezones and any detected one, payload space requirement | +| `inventory` | `{}` | Available disks with model, capacity, serial, WWN and existing partitions; layouts, locales, timezones and payload space requirement | +| `rescan` | `{}` | Invalidates any review, asks Anaconda to scan again, and returns a fresh inventory | +| `diagnostics` | `{}` | Allowlisted installer phase, progress, worker service state and backend readiness; no raw logs or personal data | | `geolocate` | `{}` | Timezone from Anaconda's geolocation task, or empty; changes no selection | | `keyboard` | `{"keyboard":"us"}` | Applied live keyboard and boot keymap | | `plan` | Account fields below | Review token, disk identity, account policy, disk actions, warnings | @@ -122,7 +128,8 @@ guards or invoke a real installation. | `reboot` | `{}` | Reboots only when installation state is complete | `plan` fields are `username`, `password`, `confirm`, `keyboard`, `locale`, -`timezone`, `hostname`, `disk`, and optional boolean `encrypted` (default true). +`timezone`, `hostname`, `disk`, optional boolean `encrypted` (default true), +and optional boolean `passwordless_wheel` (default false). `disk` is an Anaconda disk name such as `vda`, not an arbitrary path. `install` input is `{"token":"…","confirmed_disk":"vda","erase_confirmed":true}`. The final record is `{"event":"result","ok":true,"data":{…}}`, or @@ -130,7 +137,7 @@ The final record is `{"event":"result","ok":true,"data":{…}}`, or `worker` is an internal systemd entry point and does not accept user settings. The review token expires after 30 minutes. Before installation, the controller -rechecks disk identity, selected disks, applied partitioning, the exact set of +rechecks disk identity and existing partitions, selected disks, applied partitioning, the exact set of pending actions, and storage validation. The set is compared without order: `GetActions()` re-sorts blivet's list on every call, and its topological sort reverses independent actions each time. A rejected confirmation returns to disk @@ -154,6 +161,10 @@ token before commit, task path, and progress. The browser uses a disposable runtime profile with password saving, form history, and crash-session restore disabled. Exceptions returned to the UI exclude raw DBus parameters. The welcome launcher requests `--nosave=all_ks` to avoid saving generated account metadata. +Status and exported diagnostics expose only allowlisted phase, progress and +service fields. Anaconda's free-form task messages and journals are never +copied into the browser export because they may contain personal data. A failed +installation stays blocked pending diagnosis; the page never retries disk writes. The existing post-install hook removes the live account, temporary permissions, live installer page/helpers/browser configuration, and live services. It calls @@ -166,6 +177,11 @@ non-chroot post hook invokes `cybexos-installer-target`, which checks the actual before writing `/etc/cybexos/login.json`. This versioned nonsecret policy names the installed user, requested autologin and `live: false`. `/etc/cybexos/installation.json` records installation and keyring policy metadata. +The target hook removes any wheel sudo rule inherited from the live image. +Fresh offline configuration also defaults `passwordless_wheel` to false, so a +stock Advanced installation requires an administrator password. The guided +installer changes that policy and installs a mode-0440 sudoers rule only after +the user explicitly selects passwordless sudo and confirms the review. The temporary live account has a separate policy with `live: true` and a root-owned `/run/cybexos-live-session` marker. The login helper accepts this @@ -186,8 +202,24 @@ validation. Node fixtures cover UI state, confirmation, navigation locks, and failure recovery. Image tests load the welcome QML offscreen in both modes and drive its wallpaper row against a scripted shell. The headless browser fixture exercises the three pages with a mocked backend. - -Still required before an ISO can be called working: actual Cockpit loading and +`image/test_qualification.py` checks URL and disk identity guards without +starting a VM. + +The opt-in `image/qualify` harness drives the guest's actual Cockpit page +through a local SSH tunnel and host Playwright. It discovers the guest URL from +the live browser command line, selects only the disk with the fixed disposable +serial, and verifies that a second attached disk's image hash is unchanged. +`--scenario` selects encrypted/plain storage and US/NL (or DE) keyboard and +locale. A `--candidate-rpm` must have a newer installed RPM version; the +optional `--recovery-check` creates a point before upgrade, boots its GRUB +entry, restores it and verifies the baseline RPM returned. The report at +`OUTPUT/qualification.json` records the exact ISO and candidate RPM SHA-256, +scenario, checks and final status. Credentials are passed to the browser driver +on stdin and excluded from its output. Source tests do not establish a +successful VM installation. + +Still required before an ISO can be called working: execution and observation +of actual Cockpit loading and authorization, Fedora DBus behavior, UEFI/BIOS boot, encrypted and unencrypted installation, keyboard input at LUKS unlock, autologin, post-script ordering, offline payload completeness, driver/hardware behavior, and clean installed diff --git a/image/QUALIFICATION-2026-09-26-LIFECYCLE.md b/image/QUALIFICATION-2026-09-26-LIFECYCLE.md new file mode 100644 index 00000000..b66a7288 --- /dev/null +++ b/image/QUALIFICATION-2026-09-26-LIFECYCLE.md @@ -0,0 +1,222 @@ +# ISO lifecycle qualification, 26 September 2026 + +Status: the corrected replacement ISO passed all five UEFI QEMU scenarios +(88 checks), is checksum-verified, and is listed in iVentoy. This report does +not qualify a public release. + +## Candidate and evidence + +The candidate was built from clean source +`a991a97d099936298edfa7ad4c1747a3d0261f74` (`source_dirty=false`). Its source +archive SHA-256 is +`a0f0462f828f637c68c5dcb8233a78e5f32541223aef1b76f60cdc0d040bb1cc`. +The [build manifest](qualification-results/2026-09-26/build.json) binds the +source and artifacts. Each final report's `source_revision` records its +qualification harness revision. +The later `bd04954` change only fixes qualification-tool socket paths; it does +not change the installed ISO runtime. + +Artifacts retained on `john@10.10.0.7` for private installation and upgrade review: + +| Artifact | Path | Bytes | +| --- | --- | ---: | +| Private testing ISO | `/data/pxe/iso/CybexOS-Live-44-20260926T150946Z-99900b99.iso` | 7,633,059,840 | +| Matching unsigned testing RPM | `/data/cybexos-candidate-rpm-20260926-a991a97/cybexos-desktop-0.0.0~dev-1.20260926150946.ga991a97d0999.fc44.x86_64.rpm` | 1,720,330,844 | + +Each has an adjacent SHA-256 sidecar (112 bytes for the ISO; 139 bytes for +the RPM). Six compact JSON files in `image/qualification-results/2026-09-26/` +retain 8,929 bytes of build provenance and final qualification evidence. Digests: + +```text +ISO 7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635 +RPM e42fa8fdab34eab40d32e93fea9b8c9399abdff1c5be40d8f60207984c41ed8b +``` + +Build and served-copy checksums passed. iVentoy refresh returned success, +the new filename was listed, PXE reported running, and the service was active. + +| Final UEFI scenario | Status | +| --- | --- | +| Unencrypted US | Passed: [12 checks](qualification-results/2026-09-26/plain-us.json), harness `bd04954` | +| Encrypted Dutch | Passed: [25 checks](qualification-results/2026-09-26/encrypted-nl.json), harness `bd04954` | +| Encrypted US | Passed: [25 checks](qualification-results/2026-09-26/encrypted-us.json), harness `a991a97` | +| Unencrypted Dutch | Passed: [12 checks](qualification-results/2026-09-26/plain-nl.json), harness `bd04954` | +| Older-ISO RPM upgrade and GRUB recovery | Passed: [14 checks](qualification-results/2026-09-26/upgrade-recovery.json), harness `bd04954` | + +Every scenario uses disposable serial-identified installation and guard disks, +with outbound guest networking blocked. The guard disk must remain unchanged. +A pass requires installed boot without the ISO, desktop/application checks, +selected locale/timezone/keyboard, enforcing SELinux, and removal of live-only +privileges. Fresh installs must require a sudo password. Fresh encrypted scenarios +also exercise login/keyring recovery. Upgrade/recovery checks must preserve +user Kitty edits, shell preferences and a home-directory marker. + +The prior-image baseline is +`/data/pxe/iso/CybexOS-Live-44-20260926T055804Z-dbdd33d6.iso`. +It deliberately uses an installer-only session target. Its explicit legacy +qualification path starts the full desktop only after checking that target +and its marker; fresh candidates retain strict normal-startup requirements. + +Physical PXE client boot, Secure Boot, and this laptop's post-upgrade hardware +behavior are outside the QEMU qualification scope. + +## Reference workstation + +The reference is a Dell XPS 14 DA14260 running Fedora 44 from the ISO/RPM path. +Its runtime is under `/usr/share/cybexos`, without a source-checkout `current` +symlink. It uses encrypted Btrfs and enforcing SELinux; Secure Boot is disabled. +The battery was plugged in and paused at its 75–80% preservation thresholds. + +These observations informed the RPM channel, account migrations, hardware +continuation, diagnostics, and charge-limit status changes. The workstation +was inspected but was not upgraded, reconfigured, or rebooted for these tests. + +## Other verification + +| Check | Result | +| --- | --- | +| Initial local repository suite (historical) | All 17 stages passed, including 1,157 JavaScript tests. The opt-in live Quickshell stage was skipped. Later GTK and harness corrections have focused regression checks and current hosted CI coverage. | +| GitHub checks | Both required Fedora source/image checks passed at runtime/harness head `bd04954`. Checks for the final documentation and evidence commit are attached to [PR #1](https://github.com/DigitalPals/CybexOS/pull/1/checks). | +| Real RPM signing integration | Disposable RPM signed using a signing-subkey-only keyring; independent RPM/repository signatures, metadata binding, and tamper rejection passed. Nothing was installed or published. | +| Generic Fedora 44 VM at `86f185f` | First convergence: 135 changes. Second convergence: zero changes (`ok 220`). Uninstall: 22 changes; adopted files restored and project state removed. | +| Ephemeral PXE runner service | Actual transient user-service startup and cleanup passed. No GitHub runner was registered. | +| Reference workstation shell | Final guard passed its start/end checks: active managed MainPID 41596 was the sole Quickshell process, with a clean current-invocation journal. | +| Harness regressions | Sequential Quickshell IPC clients, unknown/persistent extras, localized console prompts, browser preflight, and bounded redacted audit failures passed focused tests. | +| GTK provisioning at `a991a97` | 26 focused tests passed, including real Ansible offline skipping and inherited-descriptor regressions. The actual task also passed against installed gsettings/dconf in private HOME/XDG directories and a private dconf profile: first run applied dark defaults, second made no changes, and explicit light/custom-theme choices survived. Temporary files and private processes were removed; workstation settings were untouched. | + +The generic VM predates later archive, installer and harness corrections; +it does not substitute for final ISO qualification. Its 1.2 GiB staging was +removed. + +## Findings and qualification corrections + +- Source archives omitted the bundled agent skill. The archive and an + extracted-tree packaging regression now include it. +- Anaconda's common-locales shortlist omitted Dutch. The installer now + enumerates its full API inventory: 180 available locales across 85 languages. +- Offline provisioning assumed `/var/lib/systemd/linger` existed. It now + creates the root-owned directory before the account marker; a real Ansible + regression covers a missing parent and a second idempotent run. +- A controlled backend lock reproduced the visible initialization-busy error. + The browser driver retries only that initial state through the UI, within + the original deadline and before any disk action. Other failures stop. +- Console bootstrapping now distinguishes echoed commands from output, + confirms Bash before Bash-specific setup, and recognizes the observed Dutch + sudo OCR error only with the sudo prefix and disposable account name. +- Welcome can start another Quickshell IPC client while the audit waits for + an earlier one. The guard now rescans and inspects every observed extra PID, + with a bound and the same final sole-managed-PID requirement. The historical + extra PID from the failed US run could not be classified retrospectively. +- Missing browser dependencies now fail before a VM/output directory is + created. Installed-audit failures retain bounded, password-redacted details + without echoing whole Python heredocs over the useful traceback. +- The US installed audit reported `Installed timezone differs` even though + Anaconda had selected UTC correctly. This Fedora workstation uses hardlinks + for `UTC` and `Etc/UTC`; the audit now compares file identity rather than + resolved path names. A generated-code regression covers hardlink and symlink + aliases and rejects a different zone. +- Recovery verification now requires the exact requested snapshot ID, matching + the snapshot tool's `recoveryBoot` string. Its regression uses the actual + index producer. The user-preference fixture also handles an omitted bar + position as the desktop's effective `top` default. +- Recovery uses an in-memory root overlay, whose encryption ancestry cannot be + verified by normal login policy. The real older-ISO run upgraded successfully + and preserved user choices, then its harness incorrectly expected autologin + on recovery boot. Recovery qualification now unlocks the encrypted disk, + waits for the SDDM greeter, signs in with the fixture password, and requires a + working desktop before restore. Session readiness precedes VT discovery, + because SSH can become available before SDDM has created a login session. +- A later recovery run verified the exact snapshot and completed its restore + command, then SSH disconnected during poweroff. The harness failed before + waiting for QEMU to exit. Shutdown now accepts SSH exit 255 only after a + guest marker confirms successful preparation and QEMU exits with status 0 + within the existing deadline. Sync, temporary-access cleanup, authentication, + timeout and abnormal-exit failures remain fatal. +- An earlier encrypted-US run timed out waiting for SSH after its first cold + reboot. The retained logs did not establish a cause. Readiness failures now + record bounded, password-redacted SSH and screen diagnostics and remove the + temporary screenshot. A standalone retry passed all 25 checks, + including SSH readiness and keyring recovery after that cold reboot. +- A replacement test launch failed before boot because its QMP socket path + exceeded Linux's Unix-socket pathname limit; normal nested release-runner + paths could do the same. The harness now allocates a short private socket + directory, records its actual location in `vm.json`, and removes it after + confirmed guest exit. Real socket-bind regressions cover long output paths, + repeated boots, retained diagnostic logs, and startup failures. The ISO + payload is unaffected. + +Modified diagnostic guests and superseded images cannot count as final proof. +Their useful findings are recorded here instead of retaining large artifacts. + +A later baseline run established a runtime provisioning defect: the GTK-default +task used `dbus-run-session -- gsettings` inside Bash command substitution. +An activated `gvfsd-fuse` inherited the output pipe, keeping Bash and offline +Ansible provisioning blocked after the settings command returned. The guest's +pipe holders were verified before terminating that one daemon to continue +diagnosis. That modified guest is excluded from final qualification. The first +`a1713ff` candidate had passed four fresh scenarios but packaged the same +defective task; it was superseded by the candidate above, which passed a new +complete qualification matrix. The fix skips GTK bus initialization +offline, leaving appearance defaults to the first desktop session. Live +provisioning uses a bounded private bus and separate regular-file captures for +each call, so a surviving service cannot hold Ansible's pipes open or corrupt +the next settings read. The modified diagnostic guest subsequently completed +the RPM upgrade, exact recovery boot and restore, baseline-version check, and +user-choice preservation; it remains excluded from final qualification. + +## Release configuration and limits + +The signed repository destination, public key, protected branch checks, +immutable-release setting, Pages configuration, signing environment, and +baseline variable were independently verified through the GitHub API. No +repository runners are registered. Signing runs on a separate hosted Fedora +container; the PXE runner receives no private signing key. See +[the release guide](../docs/iso-releases.md) for on-demand runners and gates. + +No production desktop RPM or public repository metadata has been published. +Project code is MIT; bundled third-party software and artwork retain their own +terms. Their redistribution/provenance review remains a public-release gate. + +This private qualification build explicitly includes the pinned stable channel +with its repository enabled. Because Pages has no metadata yet, ordinary DNF +operations on an installation of this candidate can fail on that repository. +For private testing before publication, disable only this repository: + +```bash +sudo sed -i 's/^enabled=1$/enabled=0/' /etc/yum.repos.d/cybexos-desktop.repo +``` + +This preserves signature checks and the pinned key. After publication, verified +channel enrollment re-enables it. `cybex update-channel status` checks local +configuration, not remote availability. Ordinary builds without +`--update-channel` ship a disabled desktop channel. + +The protected primary signing keyring is retained locally at +`/home/john/.local/state/cybexos/release-signing/DigitalPals-CybexOS` +(76 KiB, owner-only directory). Only its signing subkey was uploaded to the +GitHub environment secret. The public fingerprint is +`16C60642B7278AECE3A933C354220839FDF7099E`. + +## Cleanup + +All task build/cache/dependency staging (29 GiB), disposable VM disks, logs, +screenshots, temporary harness worktrees, and QMP socket directories were +removed after the five scenarios finished. The superseded task ISOs +`CybexOS-Live-44-20260926T114433Z-a107c90a.iso` and +`CybexOS-Live-44-20260926T123305Z-901ef7db.iso`, their checksums, and old RPM +directories `/data/cybexos-candidate-rpm-20260926` and +`/data/cybexos-candidate-rpm-20260926-a1713ff` were removed. Superseded local +results and diagnostic traces were removed after recording their findings. + +The final retained artifacts and signing keyring are listed above. The original +baseline ISO/checksum, Alpine ISO, unrelated remote checkout, and pre-existing +remote image outputs were preserved. The local image-output directory is +absent. Final process, mount, temporary-directory and worktree checks found no +task leftovers; directory inventory and disk usage were verified. + +After removing the last superseded ISO, iVentoy refresh returned +`result: success`. PXE reported `running`, `iventoy.service` was `active`, and +the image tree contained exactly the qualified candidate, original baseline, +and Alpine. The removed image was absent. Candidate ISO, baseline ISO, and +matching RPM checksum verification all passed again. No service restart was +needed. diff --git a/image/README.md b/image/README.md index 145789d8..c2c334ae 100644 --- a/image/README.md +++ b/image/README.md @@ -5,17 +5,14 @@ application set. The proposed boot path is **Cybex firmware menu → Cybex Plymouth → live desktop/welcome → three-screen installer**. After an encrypted installation, it is **disk unlock → automatic login → desktop**. -The September 23 implementation has source, Qt, backend-fixture and browser -checks only. **No ISO was built or booted for these changes.** The earlier -image in [VALIDATION.md](VALIDATION.md) predates this implementation and does -not qualify it. See [IMPLEMENTATION-2026-09-23.md](IMPLEMENTATION-2026-09-23.md) -for changes and remaining integration checks, and the historical -[audit](AUDIT-2026-09-23.md) for the original findings. -The September 24 source also replaces GDM with SDDM and shares the workstation's -login policy. Earlier GDM boot results do not qualify this migration. -The replacement desktop RPM built successfully in a disposable Fedora VM. -ISO creation was then deferred at the user's request; no new ISO was completed -or booted, and temporary build artifacts were removed. +Current ISO installation, desktop RPM upgrade, and recovery results are in the +[September 26 qualification report](QUALIFICATION-2026-09-26-LIFECYCLE.md), +including exact image hashes, harness revisions, and remaining release limits. +The earlier encrypted-login results are recorded in the +[September 25 installation audit](INSTALL-AUDIT-2026-09-25.md). +Earlier implementation and audit notes are historical: the +[September 23 implementation](IMPLEMENTATION-2026-09-23.md) and +[September 23 audit](AUDIT-2026-09-23.md). ## Installation experience @@ -97,8 +94,9 @@ The image package includes the reviewed Ansible baseline, account, Fish and XPS hardware tasks under `/usr/share/cybexos/provision`. Package selection includes the shared baseline and hardware firmware lists. Anaconda applies account settings, service enablement, the firewall and hardware detection in -its offline target. The installed account therefore starts with Fish and -passwordless wheel sudo; an explicit saved `passwordless_wheel: false` wins. +its offline target. The installed account starts with Fish and password-required +sudo. The installer offers an explicit opt-in to passwordless sudo; existing +saved choices are preserved on later configuration runs. Both Codex and Claude use the interactive Fish aliases in the shared config. The installer records its choices in `/etc/cybexos/config.yml`, in the same @@ -136,8 +134,20 @@ After boot, `cybexos-hardware-setup.timer` applies the detected hardware role when network access is available. Failed setup remains visible in the service journal. A known camera ABI mismatch is cached for that kernel and provisioning payload, avoiding repeated builds; a new kernel or changed payload retries it. -`cybex doctor` reports a cached incompatibility, and `cybex repair --hardware` -allows an explicit retry. Successful hardware setup is also keyed to the kernel. +`cybex doctor --json` reports installed-system diagnostics. Hardware setup +status lives in `/var/lib/cybexos/hardware-status.json` and is shown in the +welcome window; a pending camera setup resumes after rebooting into the same +kernel. Successful hardware setup is also keyed to the kernel. + +ISO installations use RPM upgrades for desktop changes. The default image has +no enabled desktop RPM update channel; inspect channel state with +`cybex update-channel status --json`. Enrolling a channel requires its reviewed +public configuration and complete signing-key fingerprint. RPM upgrades +schedule versioned account and machine policy through +`cybexos-reconcile.service` and its timer. Inspect or retry that work with +`sudo /usr/libexec/cybexos-reconcile --status` or `--retry`. `cybex repair` +reapplies the policy bundled with the installed RPM; it does not deploy a +source checkout. For installations made before this integration, preview and apply the migration from a reviewed checkout: @@ -164,9 +174,16 @@ of optional Steam/network services, and selected files use the Flatpak document portal. The session target avoids the implicit ordering cycle with vendor services that start after `graphical-session.target`. -This migration does not publish a CybexOS RPM update repository. A signed public -channel is still required for future desktop RPM delivery; see the release -instructions below. Fedora, vendor and Flatpak updates work independently. +The release tooling prepares immutable-tagged desktop RPM releases and signed +repository metadata for +[`https://digitalpals.github.io/CybexOS/44/x86_64`](https://digitalpals.github.io/CybexOS/44/x86_64). +The public channel configuration is `image/channels/stable.json`. The signing +environment and baseline ISO are configured; Pages remains empty until a +reviewed release passes its gates using an on-demand PXE runner. See the +[release instructions](../docs/releasing.md). The repository code is MIT +licensed; a separate third-party software and asset redistribution audit is +still required before public distribution. Until publication and channel +enrollment, Fedora, vendor and Flatpak updates work independently. ## Source checks: no ISO or VM @@ -276,9 +293,11 @@ versions come from `VERSION`, with a timestamp/revision release suffix and installed provenance. Epoch 1 permits upgrading the older hardcoded alpha version. Existing checkout installations retain their source updater. -A default build ships a **disabled** desktop update channel. Enabling actual -desktop updates requires your HTTPS repository URL and existing signing key. -Prepare a public configuration before building, for example: +A build without `--update-channel` ships a **disabled** desktop update channel. +The release gate explicitly enables `image/channels/stable.json`; a custom or +private channel can also be supplied with `--update-channel` at build time. +Local `cybex update-channel status` output does not prove that remote metadata +is available. For a custom channel, prepare a public configuration, for example: ```json { @@ -311,14 +330,21 @@ verifies the RPM signatures using only the public key, signs/verifies metadata and the release manifest, writes checksums, and atomically publishes a new local directory. Original RPMs and the system RPM keyring remain untouched. `--gnupghome` can select an existing signing keyring. Hosting/deployment is a -separate action; the tool does not upload anything or create signing keys. -No real signed repository was created for this implementation. - -## Future ISO qualification and PXE publication - -These commands are opt-in operations, **not part of source checks**. They were -not executed for the September 23 changes. Completed testing ISOs belong in -`/data/pxe/iso`; keep incomplete builds outside that tree. +separate action; the tool does not upload anything or create signing keys. The +public GitHub Pages/RPM release tooling, baseline ISO variable, and signing +environment are configured. First publication still requires a reviewed +queued release, an operator-started ephemeral PXE runner, and passing release +gates. The repository-code MIT license does not resolve the separate +third-party software and asset redistribution audit. See +[the release instructions](../docs/releasing.md). + +## ISO qualification and PXE publication + +These commands are opt-in operations, **not part of source checks**. The +qualification report above identifies the exact tested runtime source and +artifacts; later runtime changes require a new build and qualification. +Completed testing ISOs belong in `/data/pxe/iso`; keep incomplete builds outside +that tree. On the iVentoy host, `image/publish-pxe /path/to/artifacts` verifies the artifact set and prints a plan. Adding `--execute` copies the ISO/checksum through @@ -346,11 +372,13 @@ The harness detects Fedora/Debian UEFI firmware (raw or qcow2), uses bounded readiness checks and blocks guest outbound networking. `--firmware bios` selects BIOS. An adjacent `ISO-FILENAME.iso.sha256` must verify before a VM can start. The smoke test checks the live desktop/applications; qualification -also installs to its newly created, serial-identified virtual disk, reboots -without the ISO, unlocks it and checks encryption, autologin, desktop defaults, -SELinux and live-account cleanup. It verifies an encrypted GNOME login keyring -without requesting an unlock, stores a synthetic secret, and confirms that -secret is available after another cold boot. Logout, compositor crash and +also installs to its newly created, serial-identified virtual disk, checks that +a second guard disk is unchanged, and reboots without the ISO. It verifies the +selected locale, timezone and keyboard, desktop defaults, SELinux and +live-account cleanup. Plain scenarios require password login; encrypted +scenarios check disk unlock and autologin, then verify an encrypted GNOME login +keyring without requesting an unlock, store a synthetic secret, and confirm +that secret is available after another cold boot. Logout, compositor crash and SDDM restart must return to a greeter; password login must restore both the desktop and keyring. Another cold boot temporarily disables the cached-password PAM module in the disposable guest, checks that the vault stays locked, then @@ -358,14 +386,16 @@ verifies recovery through normal password login. The final cold boot injects a single early launcher failure in the guest. It requires a working greeter, a consumed autologin attempt, disabled autologin after restarting SDDM, and successful password-login recovery; the original launcher is then restored. -It drives the backend; the -browser fixture separately covers frontend flow. A passing fixture is not a -boot result. - -Installation qualification uses four virtual CPUs, 16 GiB RAM and a new -100 GiB sparse disk. It performs one live boot and four installed cold boots; -the three logout/crash/restart cases reuse the running installation. The -installer deadline defaults to 30 minutes (`--install-timeout 1800`); boot, +Chromium and Playwright operate the actual guest Cockpit installer through a +restricted SSH tunnel. The separate browser smoke fixture provides source +checks; it does not replace the graphical installation and boot results. + +Installation qualification uses four virtual CPUs, 16 GiB RAM and two new +100 GiB sparse disks: an installation disk and an untouched guard. Fresh encrypted +scenarios perform one live boot and four installed cold boots; the three +logout/crash/restart cases reuse the running installation. Plain scenarios +perform one live boot and one installed boot. The installer deadline defaults +to 30 minutes (`--install-timeout 1800`); boot, application seeding and recovery have separate bounded waits. Runtime has not yet been benchmarked. @@ -381,12 +411,8 @@ does not establish that PXE publication and refresh succeeded. `vm.json`. By default cleanup removes disks, credentials, screenshots and VM logs; small JSON reports remain. `--keep-artifacts` is only for unresolved diagnostics, and retained paths/sizes must be reported and later cleaned. -The 2026-09-25 UEFI qualification passed a fresh encrypted installation, -installed-account defaults, cold reboot, logout/crash/manager-restart recovery, -and the encrypted-keyring fallback cases; see -[the installation audit](INSTALL-AUDIT-2026-09-25.md). The graphical bootstrap -waits for the desktop and a terminal execution marker before private input; -it stops instead of blindly retrying passwords. +The graphical bootstrap waits for the desktop and a terminal execution marker +before private input; it stops instead of blindly retrying passwords. Physical GPUs, Secure Boot, international early-boot password entry, screen lock, suspend/resume and real application/account credential behavior need separate checks. @@ -400,7 +426,9 @@ NVIDIA drivers are not bundled. Investigate graphics with `lspci -nnk`, `hyprctl monitors all` and `journalctl -b -k`; basic-graphics recovery intentionally disables normal modesetting. -This remains a private alpha. The repository has no software license; -`LicenseRef-Not-Licensed` grants no distribution rights. Public distribution -requires the project's licensing/redistribution decisions, actual update -hosting and successful release/hardware qualification. +The repository's original code and configuration are MIT-licensed. That does +not grant redistribution rights for third-party packages, artwork, fonts, +trademarks, or bundled images. The ISO and its application payload still need +a documented redistribution audit before public release; see +[licensing and asset provenance](../docs/licensing.md). A passing VM gate +also does not qualify physical hardware or Secure Boot. diff --git a/image/applications b/image/applications index cb77287e..7daf153a 100755 --- a/image/applications +++ b/image/applications @@ -40,7 +40,10 @@ PACKAGE_TASKS = ( ("roles/base/tasks/main.yml", {"Install Docker packages when selected"}), # Firmware must be available at the first physical boot, before the # detected hardware role runs its machine-specific configuration. - ("roles/xps-2026/tasks/packages.yml", {"Install explicit Panther Lake firmware, media, and regulatory packages"}), + ("roles/xps-2026/tasks/packages.yml", { + "Install explicit Panther Lake firmware, media, and regulatory packages", + "Install full Panther Lake video-codec acceleration", + }), ("roles/desktop/tasks/main.yml", {"Install stable Hyprland and desktop integration packages", "Install Fedora fuzzel and matugen rather than COPR variants"}), ) @@ -60,7 +63,8 @@ def package_names(): for path, names in PACKAGE_TASKS: for task in yaml.safe_load((ROOT / path).read_text()): if task.get("name") in names: - packages.extend(task["ansible.builtin.dnf"]["name"]) + selected = task["ansible.builtin.dnf"]["name"] + packages.extend([selected] if isinstance(selected, str) else selected) packages += ["tailscale", "java-25-openjdk-devel", "rpmfusion-free-release", "rpmfusion-nonfree-release"] return sorted(set(packages)), defaults["apps_flatpaks"] + defaults["apps_steam_flatpaks"] diff --git a/image/browser-smoke.cjs b/image/browser-smoke.cjs index 9014906a..6805d974 100644 --- a/image/browser-smoke.cjs +++ b/image/browser-smoke.cjs @@ -6,6 +6,7 @@ const fs = require("node:fs/promises"); const http = require("node:http"); const path = require("node:path"); const { chromium } = require("playwright-core"); +const { waitForInitialSetup } = require("./real_browser_qualification.cjs"); const root = path.join(__dirname, "live-rootfs/usr/share/cockpit/cybexos-installer"); const transport = ` @@ -20,11 +21,34 @@ window.cockpit = { then(callback) { done = callback; return this; }, input(raw) { const data = JSON.parse(raw || "{}"), command = args[1]; - fixtureRequests.push({ command, hasPassword: !!data.password, timezone: data.timezone }); + fixtureRequests.push({ command, hasPassword: !!data.password, + locale: data.locale, timezone: data.timezone, encrypted: data.encrypted }); + const busyRemaining = Number(sessionStorage.getItem("fixtureBusyRemaining") || "0"); + if (command === "inventory" && busyRemaining > 0) { + sessionStorage.setItem("fixtureBusyRemaining", String(busyRemaining - 1)); + setTimeout(() => { + stream(JSON.stringify({ event: "result", ok: false, + error: "Another installer operation is still running." }) + "\\n"); + done(); + }, 5); + return this; + } + const initialError = command === "inventory" && sessionStorage.getItem("fixtureInitialError"); + if (initialError && initialError !== "done") { + sessionStorage.setItem("fixtureInitialError", "done"); + setTimeout(() => { + stream(JSON.stringify({ event: "result", ok: false, + error: "Anaconda inventory failed." }) + "\\n"); + done(); + }, 5); + return this; + } let result; if (command === "status") result = { phase: fixturePhase, message: "Fixture progress" }; - else if (command === "inventory") result = { - disks: [{ name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe" }], + else if (command === "inventory" || command === "rescan") result = { + disks: [{ name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", + serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", + partitions: [{ path: "/dev/vda1", size: 2147483648, filesystem: "vfat" }] }], keyboards: [{ id: "us", label: "English (US)" }, { id: "nl", label: "Dutch" }], locales: ["en_US.UTF-8", "nl_NL.UTF-8"], locale: "en_US.UTF-8", keyboard: "us", timezones: ["America/Argentina/Buenos_Aires", "Europe/Amsterdam", "UTC"], @@ -34,9 +58,12 @@ window.cockpit = { else if (command === "keyboard") result = { keyboard: data.keyboard, boot_keyboard: data.keyboard }; else if (command === "plan") result = { phase: "review", token: "fixture-token", - disk: { name: "vda", path: "/dev/vda", model: "Fixture NVMe" }, + disk: { name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", + serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", + partitions: [{ path: "/dev/vda1", size: 2147483648, filesystem: "vfat" }] }, account: { username: data.username, encrypted: data.encrypted, - locale: data.locale, timezone: data.timezone }, + locale: data.locale, timezone: data.timezone, + passwordless_wheel: data.passwordless_wheel }, boot_keyboard: data.keyboard, actions: [{ "action-description": "Create", "object-description": "encrypted Btrfs", "device-name": "vda" }], warnings: [] @@ -59,7 +86,11 @@ window.cockpit = { fixturePhase = "complete"; sessionStorage.setItem("fixturePhase", fixturePhase); }, 100); - } else result = { phase: "setup" }; + } else if (command === "diagnostics") result = { + schema: 1, installer: { phase: fixturePhase, message: "Installation did not finish." }, + worker: { ActiveState: "failed", Result: "exit-code" }, backend_ready: true + }; + else result = { phase: "setup" }; setTimeout(() => { stream(JSON.stringify({ event: "result", ok: true, data: result }) + "\\n"); done(); @@ -106,6 +137,22 @@ async function main() { headless: true, args: ["--disable-dev-shm-usage"], }); + const busyPage = await browser.newPage(); + await busyPage.addInitScript(() => sessionStorage.setItem("fixtureBusyRemaining", "4")); + await busyPage.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); + await waitForInitialSetup(busyPage, 5000, [10, 20, 40, 80, 160, 320, 320]); + assert.equal(await busyPage.locator("#setup").isVisible(), true); + assert.equal(await busyPage.evaluate(() => fixtureRequests.filter(request => request.command === "inventory").length), 5); + await busyPage.close(); + + const otherErrorPage = await browser.newPage(); + await otherErrorPage.addInitScript(() => sessionStorage.setItem("fixtureInitialError", "other")); + await otherErrorPage.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); + await assert.rejects(waitForInitialSetup(otherErrorPage, 250)); + assert.equal(await otherErrorPage.locator("#setup").isVisible(), false); + assert.equal(await otherErrorPage.evaluate(() => fixtureRequests.filter(request => request.command === "inventory").length), 1); + await otherErrorPage.close(); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); await page.emulateMedia({ reducedMotion: "reduce" }); const errors = []; @@ -115,8 +162,17 @@ async function main() { await page.waitForFunction(() => !document.querySelector("#password").disabled); await screenshot(page, "installer-setup"); await page.fill("#username", "alice"); + assert.equal(await page.inputValue("#disk"), ""); + assert.equal(await page.isChecked("#passwordless-wheel"), false); + await page.fill("#username", "root"); await page.fill("#password", "fixture secret 123"); await page.fill("#confirm", "fixture secret 123"); + await page.getByRole("button", { name: "Choose install location" }).click(); + assert.match(await page.textContent("#error"), /System names are reserved/); + await page.fill("#username", "alice"); + await page.fill("#password", "fixture secret 123"); + await page.fill("#confirm", "fixture secret 123"); + await page.check("#passwordless-wheel"); await page.selectOption("#keyboard", "nl"); await page.waitForFunction(() => !document.querySelector("#password").disabled); assert.equal(await page.inputValue("#password"), ""); @@ -129,13 +185,23 @@ async function main() { await page.fill("#keyboard-test", "ordinary test characters"); await page.fill("#password", "fixture secret 123"); await page.fill("#confirm", "fixture secret 123"); + await page.locator("#account-form details > summary").click(); + await page.selectOption("#locale", "nl_NL.UTF-8"); + await page.selectOption("#timezone", "Europe/Amsterdam"); await page.getByRole("button", { name: "Choose install location" }).click(); await page.locator("#location").waitFor({ state: "visible" }); assert.equal(await page.isChecked("#encrypted"), true); + await page.click("#rescan-disks"); + await page.waitForFunction(() => !document.querySelector("#rescan-disks").disabled); + assert.equal(await page.inputValue("#disk"), ""); await screenshot(page, "installer-location"); await page.selectOption("#disk", "vda"); + assert.match(await page.textContent("#disk-details"), /FIXTURE-SERIAL/); + assert.match(await page.textContent("#disk-details"), /\/dev\/vda1.*2\.0 GiB/); await page.getByRole("button", { name: "Review installation" }).click(); await page.locator("#review").waitFor({ state: "visible" }); + assert.match(await page.textContent("#summary"), /Administrator commands do not ask for a password/); + assert.match(await page.textContent("#summary"), /FIXTURE-WWN/); assert.equal(await page.isDisabled("#install"), true); await screenshot(page, "installer-review"); await page.check("#erase"); @@ -153,13 +219,44 @@ async function main() { assert.equal(await page.inputValue("#confirm"), ""); assert.equal(await page.evaluate(() => fixtureRequests.filter(request => request.command === "install").length), 2); assert.equal(await page.evaluate(() => fixtureRequests.find(request => request.command === "plan").timezone), "Europe/Amsterdam"); + assert.equal(await page.evaluate(() => fixtureRequests.find(request => request.command === "plan").locale), "nl_NL.UTF-8"); await page.reload(); await page.getByRole("heading", { name: "Your workspace is ready." }).waitFor(); + await page.evaluate(() => sessionStorage.setItem("fixturePhase", "failed-install")); + await page.reload(); + await page.getByRole("heading", { name: "Installation needs attention." }).waitFor(); + assert.equal(await page.isVisible("#failure-help"), true); + const downloadPromise = page.waitForEvent("download"); + await page.click("#save-diagnostics"); + const download = await downloadPromise; + assert.equal(download.suggestedFilename(), "cybexos-installer-diagnostics.json"); + assert.equal(await page.evaluate(() => fixtureRequests.some(request => request.command === "diagnostics")), true); await page.setViewportSize({ width: 390, height: 844 }); await screenshot(page, "installer-narrow"); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true); + const plain = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + plain.on("pageerror", error => errors.push(error.message)); + await plain.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); + await plain.locator("#setup").waitFor({ state: "visible" }); + await plain.waitForFunction(() => !document.querySelector("#password").disabled); + await plain.fill("#username", "qualification"); + await plain.fill("#password", "fixture secret 123"); + await plain.fill("#confirm", "fixture secret 123"); + await plain.locator("#account-form details > summary").click(); + await plain.selectOption("#locale", "en_US.UTF-8"); + await plain.selectOption("#timezone", "UTC"); + await plain.getByRole("button", { name: "Choose install location" }).click(); + await plain.locator("#location").waitFor({ state: "visible" }); + await plain.selectOption("#disk", "vda"); + await plain.locator("#disk-form details > summary").click(); + await plain.uncheck("#encrypted"); + await plain.getByRole("button", { name: "Review installation" }).click(); + await plain.locator("#review").waitFor({ state: "visible" }); + assert.match(await plain.textContent("#summary"), /unencrypted/); + assert.equal(await plain.evaluate(() => fixtureRequests.find(request => request.command === "plan").encrypted), false); + await plain.close(); assert.deepEqual(errors, []); - console.log("PASS: three-screen flow, keyboard change, timezone detection, rejected-install recovery, encryption default, erase confirmation, password clearing, progress/reload recovery, narrow layout"); + console.log("PASS: three-screen flow, keyboard/locale/timezone choices, encrypted and plain disk paths, rejected-install recovery, erase confirmation, password clearing, progress/reload recovery, narrow layout"); } finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); diff --git a/image/browser_qualification.py b/image/browser_qualification.py new file mode 100644 index 00000000..15c37d49 --- /dev/null +++ b/image/browser_qualification.py @@ -0,0 +1,122 @@ +"""Connect a host browser to the guest's actual loopback Cockpit instance.""" +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import time +from urllib.parse import urlsplit + +from vm_testing import free_port, run + +DRIVER = Path(__file__).with_name('real_browser_qualification.cjs') +DISCOVER = r''' +from pathlib import Path +for proc in Path('/proc').iterdir(): + if not proc.name.isdigit(): + continue + try: + args = proc.joinpath('cmdline').read_bytes().split(b'\0') + except (PermissionError, FileNotFoundError, ProcessLookupError): + continue + if not any(arg.endswith(b'/cybexos-installer-browser') for arg in args): + continue + for arg in args: + if arg.startswith(b'http://127.0.0.1') or arg.startswith(b'http://localhost'): + print(arg.decode('ascii')) + raise SystemExit(0) +raise SystemExit(1) +''' + + +def validate_guest_url(value): + parsed = urlsplit(value) + if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost') + or parsed.username or parsed.password or parsed.query or parsed.fragment + or parsed.path not in ('/cockpit/@localhost/cybexos-installer/index.html', + '/cockpit/@localhost/anaconda-webui/index.html')): + raise ValueError('Guest installer URL was not the expected loopback Cockpit page') + port = parsed.port or 80 + if not 1 <= port <= 65535: + raise ValueError('Guest installer port is invalid') + return port + + +def discover_guest_port(vm, timeout=90): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = subprocess.run([*vm.ssh, 'python3 -'], input=DISCOVER, text=True, + capture_output=True, timeout=10) + if result.returncode == 0: + urls = [line for line in result.stdout.splitlines() if line.strip()] + if len(urls) != 1: + raise RuntimeError('Guest installer URL discovery was ambiguous') + return validate_guest_url(urls[0]) + vm.alive() + time.sleep(2) + raise RuntimeError('Guest installer browser URL was not discovered') + + +def wait_tunnel(port, process, timeout=20): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError('Installer Cockpit tunnel exited unexpectedly') + try: + with socket.create_connection(('127.0.0.1', port), timeout=1): + return + except OSError: + time.sleep(0.2) + raise RuntimeError('Installer Cockpit tunnel did not open') + + +def browser_dependencies(): + requested = os.environ.get('CYBEXOS_BROWSER') + browser = (shutil.which(requested) or requested) if requested else next((shutil.which(name) for name in + ('brave-origin', 'chromium', 'chromium-browser', 'google-chrome') if shutil.which(name)), None) + if not browser or not os.access(browser, os.X_OK): + raise RuntimeError('Install a Chromium browser or set CYBEXOS_BROWSER for graphical qualification') + check = subprocess.run(['node', '-e', + 'if(Number(process.versions.node.split(".")[0])<20) process.exit(1); require("playwright-core");'], + capture_output=True, text=True, timeout=10) + if check.returncode: + raise RuntimeError('Graphical qualification requires Node.js >=20 and playwright-core (see image/README.md)') + return browser + + +def qualify_browser(vm, *, password, target_disk, unused_disk, encrypted, keyboard, locale, + timezone, install_timeout, require_policy_controls=True): + browser = browser_dependencies() + remote_port = discover_guest_port(vm) + local_port = free_port() + tunnel = subprocess.Popen([*vm.ssh[:-1], '-N', '-L', + f'127.0.0.1:{local_port}:127.0.0.1:{remote_port}', vm.ssh[-1]], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + wait_tunnel(local_port, tunnel) + payload = {'url': f'http://127.0.0.1:{local_port}/cockpit/@localhost/cybexos-installer/index.html', + 'browser': browser, 'password': password, 'target_disk': target_disk, + 'target_serial': 'CYBEXOS-QUALIFY', 'unused_disk': unused_disk, + 'encrypted': encrypted, 'keyboard': keyboard, 'locale': locale, + 'timezone': timezone, 'install_timeout_ms': install_timeout * 1000, + 'require_policy_controls': require_policy_controls} + try: + result = run(['node', str(DRIVER)], input=json.dumps(payload), text=True, + capture_output=True, timeout=install_timeout + 240) + except subprocess.CalledProcessError as error: + # Playwright prints the failed assertion/locator to stderr. Keep + # the useful part without ever including the fixture password. + detail = ((error.stderr or '') + '\n' + (error.stdout or '')).replace(password, '[redacted]') + raise RuntimeError(f'Installer browser driver failed: {detail[-5000:]}') from error + data = json.loads(result.stdout) + if data.get('check') != 'graphical-installer' or data.get('selected_disk') != target_disk: + raise RuntimeError('Browser driver did not confirm the selected disposable disk') + return data + finally: + tunnel.terminate() + try: + tunnel.wait(timeout=5) + except subprocess.TimeoutExpired: + tunnel.kill() + tunnel.wait() diff --git a/image/build b/image/build index 5367431b..b933504e 100755 --- a/image/build +++ b/image/build @@ -31,8 +31,9 @@ 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", + "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: diff --git a/image/channels/CYBEXOS-desktop.asc b/image/channels/CYBEXOS-desktop.asc new file mode 100644 index 00000000..f48cabad --- /dev/null +++ b/image/channels/CYBEXOS-desktop.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEarerDxYJKwYBBAHaRw8BAQdAmmD5CaWuawHOraUCulVZjFjAT0iUKf9mFBPL +2LkA9B20O0N5YmV4T1MgUlBNIFJlbGVhc2UgU2lnbmluZyA8bm9yZXBseUBkaWdp +dGFscGFscy5naXRodWIuaW8+iJkEExYKAEEWIQQWxgZCtyeK7OOpM8NUIgg5/fcJ +ngUCarerDwIbAQUJBaOagAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRBU +Igg5/fcJno2eAP98NYS4dX6fxbK0yUPM1Nb+aeH2v8P1d5WoeH2xn8j5cQD8C+lz +ETO5o+5dSmRbCJPv7bDfC9sWBNznW6uLqylJvwm4MwRqt6sPFgkrBgEEAdpHDwEB +B0DMqZcTm/bd+Yk8hrqRjWBi7/1yoAQ/tvfcJfYui627k4j1BBgWCgAmFiEEFsYG +QrcniuzjqTPDVCIIOf33CZ4FAmq3qw8CGwIFCQPCZwAAgQkQVCIIOf33CZ52IAQZ +FgoAHRYhBG6cYYz9wTHrUwGfYUyvtRx29FzVBQJqt6sPAAoJEEyvtRx29FzVk+sB +APoBywasY4Zv5bWN8czeindBYEauMAn7loIa62SwjOJHAP41NgT7cnuIF4moDHyp +TI83TSC9EB420/rWpPc5f3MqAtI4AQC73TWynBM+ACwHKf0n9qt4BXfjPAI/YQss +LlVC6dplXQEA06MRepyNZvoAzorba/isdLw/OVeoUsnvbF2AOl12SAc= +=61Bo +-----END PGP PUBLIC KEY BLOCK----- diff --git a/image/channels/stable.json b/image/channels/stable.json new file mode 100644 index 00000000..3b39ae44 --- /dev/null +++ b/image/channels/stable.json @@ -0,0 +1,5 @@ +{ + "baseurl": "https://digitalpals.github.io/CybexOS/44/x86_64", + "fingerprint": "16C60642B7278AECE3A933C354220839FDF7099E", + "key_file": "CYBEXOS-desktop.asc" +} diff --git a/image/cybexos-desktop.spec b/image/cybexos-desktop.spec index b90fe388..09f138c1 100644 --- a/image/cybexos-desktop.spec +++ b/image/cybexos-desktop.spec @@ -5,9 +5,9 @@ Epoch: 1 Version: 0.1.0 Release: 0.1.alpha%{?dist} Summary: CybexOS Hyprland and Quickshell desktop -# No repository license has been selected. These are private evaluation -# artifacts; this label does not grant redistribution rights. -License: LicenseRef-Not-Licensed +# CybexOS code is MIT. Bundled upstream software/artwork retains its own terms; +# aggregate redistribution clearance is still pending (docs/licensing.md). +License: MIT AND LicenseRef-CybexOS-Bundled-Components URL: https://github.com/DigitalPals/CybexOS Source0: desktop.tar BuildArch: x86_64 @@ -18,7 +18,7 @@ Obsoletes: fedora-config-desktop < %{epoch}:%{version}-%{release} # filesystem separately. Avoid spending minutes recompressing user toolchains. %global _binary_payload w3.zstdio %global _binary_filedigest_algorithm 8 -Requires: bash coreutils util-linux systemd python3 ansible-core +Requires: bash coreutils util-linux systemd python3 ansible-core gnupg2 Requires: sddm sddm-wayland-generic systemd-pam gnome-keyring-pam Requires: hyprland hyprland-guiutils quickshell hypridle hyprlock hyprpolkitagent hyprsunset Requires: xdg-desktop-portal-hyprland xdg-desktop-portal-gtk xdg-utils @@ -56,6 +56,7 @@ mkdir -p %{buildroot} cp -a usr opt etc %{buildroot}/ %files +%license /usr/share/licenses/cybexos-desktop/LICENSE %config(noreplace) /etc/yum.repos.d/cybexos-desktop.repo %config(noreplace) /etc/fonts/conf.d/49-cybexos-defaults.conf /opt/cybexos-apps/ @@ -88,14 +89,23 @@ 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-reconcile.service +/usr/lib/systemd/system/cybexos-reconcile.timer /usr/lib/systemd/system/cybexos-hardware-setup.service /usr/lib/systemd/system/cybexos-hardware-setup.timer /usr/lib/firewalld/zones/cybexos.xml %posttrans +# Record work only inside the RPM transaction. A timer runs it after RPM releases +# its transaction lock, and repeats only for changed payloads/new accounts. +/usr/libexec/cybexos-reconcile --queue +systemctl enable --now --no-block cybexos-reconcile.timer >/dev/null 2>&1 || : # The SDDM RPM owns /etc/pam.d/sddm-autologin. Install the shared policy after -# all package payloads are present, preserving its initial configuration once. -/usr/libexec/cybexos-login-prepare --install-pam +# all package payloads are present on a fresh installation. Upgrades use the +# ownership-aware deferred policy so local PAM edits are retained. +if [ "$1" -eq 1 ]; then + /usr/libexec/cybexos-login-prepare --install-pam +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-hardware-setup.timer >/dev/null 2>&1 || : diff --git a/image/desktop_payload.py b/image/desktop_payload.py index aa16047e..dc06f16b 100644 --- a/image/desktop_payload.py +++ b/image/desktop_payload.py @@ -113,3 +113,23 @@ def split_seed(vendor, contract): if file.is_file() and not file.is_symlink()) (vendor / "seed-groups.json").write_text(json.dumps({"totalBytes": total}) + "\n") return total + + +def prepare_managed_defaults(vendor): + """Version only vendor fragments; application stores and user settings stay seeded once.""" + paths = ( + '.config/fish/conf.d/50-cybexos.fish', + '.config/kitty/cybexos.conf', + '.local/share/nautilus-python/extensions/localsend.py', + ) + selected = [] + for relative in paths: + for tree in ('essential-seed', 'user-seed', 'final-seed'): + source = vendor / tree / relative + if source.is_file() and not source.is_symlink(): + destination = vendor / 'managed-seed' / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(source, destination) + selected.append(relative) + break + (vendor / 'managed-defaults.json').write_text(json.dumps(selected) + '\n') diff --git a/image/github_release.py b/image/github_release.py new file mode 100644 index 00000000..2bd3d326 --- /dev/null +++ b/image/github_release.py @@ -0,0 +1,314 @@ +"""Prepare verified GitHub Release assets and a small GitHub Pages RPM repository.""" +import argparse +import hashlib +import gzip +import json +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +from urllib.parse import urljoin +import xml.etree.ElementTree as ET + +from release_metadata import fingerprint, public_key +from release_repository import rename_new_directory, sha256, verify_signed_rpm + +ASSET_LIMIT = 2 * 1024 ** 3 +ISO_PART_SIZE = 1900 * 1024 ** 2 + + +def github_url(repository, tag): + if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', repository): + raise ValueError('Use an owner/repository GitHub name') + if not re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', tag): + raise ValueError('Use a versioned vX.Y.Z release tag') + return f'https://github.com/{repository}/releases/download/{tag}' + + +def verify_checksums(directory, filename="SHA256SUMS"): + manifest = directory / filename + checked = set() + for line in manifest.read_text().splitlines(): + match = re.fullmatch(r'([0-9a-f]{64}) (.+)', line) + if not match: + raise ValueError('Invalid checksum manifest') + if not re.fullmatch(r'[A-Za-z0-9_.+~^/-]+', match[2]): + raise ValueError('Checksum filenames must be safe ASCII paths') + name = Path(match[2]) + if name.is_absolute() or '..' in name.parts or name.as_posix() in checked: + raise ValueError('Unsafe or repeated checksum filename') + candidate = directory / name + if candidate.is_symlink() or not candidate.is_file() or not candidate.resolve().is_relative_to(directory.resolve()): + raise ValueError('Checksum file escapes the artifact directory') + if sha256(candidate) != match[1]: + raise ValueError(f'Artifact checksum mismatch: {name}') + checked.add(name.as_posix()) + inventory = set() + for path in directory.rglob('*'): + if path.is_symlink() or (not path.is_dir() and not path.is_file()): + raise ValueError('Artifact trees must contain only regular files and directories') + if path.is_file() and path != manifest: + inventory.add(path.relative_to(directory).as_posix()) + if checked != inventory: + raise ValueError('Checksum inventory does not cover the complete artifact directory') + return checked + + +def verify_release(directory, expected, temporary): + public_key(directory / 'CYBEXOS-desktop.asc', expected) + gpg_home = temporary / 'gpg' + gpg_home.mkdir(mode=0o700) + gpg = ['gpg', '--no-options', '--homedir', str(gpg_home), '--batch', '--no-autostart'] + subprocess.run([*gpg, '--import', str(directory / 'CYBEXOS-desktop.asc')], check=True, capture_output=True) + for relative in ('release.json', 'repodata/repomd.xml'): + subprocess.run([*gpg, '--verify', str(directory / (relative + '.asc')), str(directory / relative)], + check=True, capture_output=True) + + +def split_iso(source, destination, chunk_size=ISO_PART_SIZE): + """Keep the original filename/checksum, with a deterministic reconstruction manifest.""" + if not source.name.isascii() or not re.fullmatch(r'[A-Za-z0-9._-]+\.iso', source.name): + raise ValueError('ISO filename must be ASCII without spaces') + if not isinstance(chunk_size, int) or not 0 < chunk_size < ASSET_LIMIT: + raise ValueError('ISO part size must be positive and below the asset limit') + digest = hashlib.sha256() + parts = [] + with source.open('rb') as stream: + number = 0 + while True: + remaining = chunk_size + name = f'{source.name}.part-{number:03d}' + part = destination / name + part_digest = hashlib.sha256() + size = 0 + with part.open('xb') as output: + while remaining: + block = stream.read(min(8 * 1024 * 1024, remaining)) + if not block: + break + output.write(block) + digest.update(block) + part_digest.update(block) + size += len(block) + remaining -= len(block) + if not size: + part.unlink() + break + parts.append({'file': name, 'bytes': size, 'sha256': part_digest.hexdigest()}) + number += 1 + if not parts: + raise ValueError('ISO is empty') + record = {'format': 1, 'file': source.name, 'bytes': sum(item['bytes'] for item in parts), + 'sha256': digest.hexdigest(), 'parts': parts} + (destination / (source.name + '.parts.json')).write_text(json.dumps(record, indent=2) + '\n') + (destination / (source.name + '.sha256')).write_text(f"{record['sha256']} {source.name}\n") + return record + + +def verify_metadata(directory, packages): + """Bind authenticated repomd metadata to the exact RPMs we will upload.""" + namespace = {'r': 'http://linux.duke.edu/metadata/repo', + 'p': 'http://linux.duke.edu/metadata/common'} + root = ET.parse(directory / 'repodata/repomd.xml').getroot() + seen, primary = set(), None + for entry in root.findall('r:data', namespace): + location = entry.find('r:location', namespace) + checksum = entry.find('r:checksum', namespace) + if location is None or checksum is None or checksum.get('type') != 'sha256': + raise ValueError('Repository metadata requires SHA-256 locations') + relative = location.get('href', '') + path = Path(relative) + if path.is_absolute() or '..' in path.parts or len(path.parts) != 2 or path.parts[0] != 'repodata': + raise ValueError('Repository metadata location escapes repodata') + if relative in seen: + raise ValueError('Repeated repository metadata location') + seen.add(relative) + source = directory / path + if sha256(source) != checksum.text: + raise ValueError('Signed repository metadata checksum mismatch') + if entry.get('type') == 'primary': + if primary is not None: + raise ValueError('Repeated primary repository metadata') + if source.suffix != '.gz': + raise ValueError('Primary metadata must use gzip compression') + with gzip.open(source, 'rb') as stream: + content = stream.read(64 * 1024 * 1024 + 1) + if len(content) > 64 * 1024 * 1024: + raise ValueError('Primary repository metadata exceeds the size limit') + primary = ET.fromstring(content) + if primary is None: + raise ValueError('Repository is missing primary metadata') + listed = set() + records = {package['sha256']: package for package in packages} + if len(records) != len(packages): + raise ValueError('Repeated package digest') + for package in primary.findall('p:package', namespace): + checksum = package.find('p:checksum', namespace) + location = package.find('p:location', namespace) + if checksum is None or checksum.get('type') != 'sha256' or location is None: + raise ValueError('Primary package requires a SHA-256 checksum and URL') + record = records.get(checksum.text) + if record is None or checksum.text in listed: + raise ValueError('Primary metadata does not match the release packages') + listed.add(checksum.text) + base = location.get('{http://www.w3.org/XML/1998/namespace}base', '') + if urljoin(base, location.get('href', '')) != record['url']: + raise ValueError('Primary metadata package URL differs from this GitHub Release') + version = package.find('p:version', namespace) + if (package.findtext('p:name', namespaces=namespace) != record['name'] + or package.findtext('p:arch', namespaces=namespace) != record['arch'] + or version is None + or any(version.get(key) != record[field] for key, field in + (('epoch', 'epoch'), ('ver', 'version'), ('rel', 'release')))): + raise ValueError('Primary metadata package identity differs from the manifest') + if listed != set(records): + raise ValueError('Primary metadata omits a release package') + allowed = seen | {'repodata/repomd.xml', 'repodata/repomd.xml.asc'} + present = {str(path.relative_to(directory)) for path in (directory / 'repodata').rglob('*') if path.is_file()} + if present != allowed: + raise ValueError('Repository contains unreferenced metadata files') + + +def verify_qualifications(paths, iso_digest, packages): + """Require fresh installer and prior-release upgrade/recovery evidence.""" + scenarios = {'encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl'} + fresh, upgrade, reports = set(), False, [] + candidates = {package['unsigned_input_sha256'] for package in packages} + for path in paths: + path = Path(path) + if path.stat().st_size > 1024 * 1024: + raise ValueError('Qualification report exceeds the size limit') + report = json.loads(path.read_text()) + if not isinstance(report, dict) or report.get('status') != 'passed': + raise ValueError('Every qualification report must have passed') + checks = report.get('checks') + if not isinstance(checks, list) or not all(isinstance(item, str) for item in checks): + raise ValueError('Qualification checks must be a list of completed check names') + prior = report.get('iso_sha256', '') + 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: + 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)): + upgrade = True + reports.append(report) + if fresh != scenarios: + raise ValueError('Release requires all four fresh ISO installer qualifications: ' + ', '.join(sorted(scenarios - fresh))) + if not upgrade: + raise ValueError('Release requires a different prior ISO with the exact candidate RPM upgrade and recovery qualification') + return reports + + +def prepare(signed, artifacts, destination, repository, tag, expected, qualifications=()): + signed, artifacts, destination = Path(signed), Path(artifacts), Path(destination).absolute() + expected = fingerprint(expected) + downloads = github_url(repository, tag) + if destination.exists() or destination.is_symlink(): + raise ValueError('Output must be a new directory') + repository_files = verify_checksums(signed) + artifact_files = verify_checksums(artifacts) + manifest = json.loads((signed / 'release.json').read_text()) + channel = json.loads((signed / 'update-channel.json').read_text()) + owner, project = repository.split('/') + pages_url = f'https://{owner.lower()}.github.io/{project}/44/x86_64' + if (manifest.get('format') != 1 or manifest.get('baseurl') != pages_url + or channel != {'baseurl': pages_url, 'fingerprint': expected, 'key_file': 'CYBEXOS-desktop.asc'}): + raise ValueError('Release channel must match this exact GitHub Pages repository') + if manifest['fingerprint'] != expected: + raise ValueError('Release manifest uses an unexpected signing key') + packages = manifest['packages'] + if not isinstance(packages, list) or not packages: + raise ValueError('Release contains no RPMs') + package_names = set() + for package in packages: + if package.get('name') != 'cybexos-desktop' or package.get('arch') != 'x86_64' or package.get('version') != tag[1:].replace('-', '~', 1): + raise ValueError('RPM identity must match the release tag and desktop architecture') + path = signed / package['file'] + if not re.fullmatch(r'Packages/[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm', package['file']) or path.name in package_names: + raise ValueError('RPM filenames must be safe, distinct release assets') + package_names.add(path.name) + for key in ('sha256', 'unsigned_input_sha256'): + if not isinstance(package.get(key), str) or not re.fullmatch(r'[0-9a-f]{64}', package[key]): + raise ValueError('Release package is missing a complete SHA-256 identity') + if package['file'] not in repository_files or package.get('url') != downloads + '/' + path.name: + raise ValueError('RPM metadata must reference this exact GitHub Release') + if path.stat().st_size >= ASSET_LIMIT: + raise ValueError('Desktop RPM exceeds the GitHub 2 GiB asset limit; split the package before release') + if sha256(path) != package['sha256']: + raise ValueError('Signed manifest RPM digest mismatch') + isos = [artifacts / name for name in artifact_files if name.endswith('.iso')] + if len(isos) != 1: + raise ValueError('Exactly one checksum-verified ISO is required') + iso_digest = sha256(isos[0]) + reports = verify_qualifications(qualifications, iso_digest, packages) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='.cybexos-github-', dir=destination.parent) as directory: + work = Path(directory) + verify_release(signed, expected, work) + verify_metadata(signed, packages) + stage = work / 'output' + assets = stage / 'assets' + pages = stage / 'pages/44/x86_64' + assets.mkdir(parents=True) + pages.mkdir(parents=True) + for package in packages: + source = signed / package['file'] + copied = assets / source.name + shutil.copyfile(source, copied) + if sha256(copied) != package['sha256']: + raise ValueError('RPM changed during release preparation') + verify_signed_rpm(copied, signed / 'CYBEXOS-desktop.asc', work) + for name in ('release.json', 'release.json.asc', 'CYBEXOS-desktop.asc', 'update-channel.json'): + content = (signed / name).read_bytes() + if name == 'update-channel.json' and json.loads(content) != channel: + raise ValueError('Channel configuration changed during release preparation') + # Both delivery locations must contain the same snapshot; the + # copied Pages signatures are verified again below. + (assets / name).write_bytes(content) + (pages / name).write_bytes(content) + shutil.copytree(signed / 'repodata', pages / 'repodata') + iso = split_iso(isos[0], assets) + if iso['sha256'] != iso_digest: + raise ValueError('ISO changed during release preparation') + shutil.copyfile(Path(__file__).with_name('reconstruct-iso'), assets / 'reconstruct-iso.py') + (assets / 'qualification-reports.json').write_text(json.dumps(reports, indent=2) + '\n') + copied_verify = work / 'copied-verification' + copied_verify.mkdir() + verify_release(pages, expected, copied_verify) + verify_metadata(pages, packages) + # Metadata checksums contain only files hosted by Pages, never the large RPMs. + for target in (assets, pages): + files = sorted(path for path in target.rglob('*') if path.is_file()) + checksum_name = 'desktop-SHA256SUMS' if target == assets else 'SHA256SUMS' + (target / checksum_name).write_text(''.join(f'{sha256(path)} {path.relative_to(target)}\n' for path in files)) + (stage / 'pages/.nojekyll').touch() + (stage / 'pages/index.html').write_text('' + '
Signed Fedora 44 x86_64 repository. Use the public channel configuration and independently verify its signing fingerprint.
' + 'Channel configuration\n') + rename_new_directory(stage, destination) + return destination + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--signed-repository', type=Path, required=True) + parser.add_argument('--artifacts', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--repository', default='DigitalPals/CybexOS') + parser.add_argument('--tag', required=True) + parser.add_argument('--fingerprint', required=True) + parser.add_argument('--qualification', type=Path, action='append', required=True, + help='Passed VM qualification JSON; repeat for four installer scenarios and prior-release upgrade/recovery') + args = parser.parse_args() + try: + print(prepare(args.signed_repository, args.artifacts, args.output, + args.repository, args.tag, args.fingerprint, args.qualification)) + except (OSError, ValueError, KeyError, TypeError, ET.ParseError, subprocess.SubprocessError) as error: + parser.exit(1, f'github-release: {error}\n') + + +if __name__ == '__main__': + main() diff --git a/image/installer-tests/model.test.mjs b/image/installer-tests/model.test.mjs index 6773bed2..2c186182 100644 --- a/image/installer-tests/model.test.mjs +++ b/image/installer-tests/model.test.mjs @@ -7,6 +7,12 @@ test("account validation handles mismatch, username and a long non-ASCII phrase" assert.throws(() => wizard.account({username: "Alice", password: "long fixture password", confirm: "long fixture password"})); assert.throws(() => wizard.account({username: "alice", password: "long fixture password", confirm: "other"})); assert.equal(wizard.account({username: "alice", password: "lang wachtwoord café", confirm: "lang wachtwoord café"}).username, "alice"); + for (const username of ["root", "liveuser", "sddm", "chrony"]) { + assert.throws(() => wizard.account({username, password: "long fixture password", confirm: "long fixture password"}), /reserved/); + } + for (const password of ["long\nfixture password", "x".repeat(513)]) { + assert.throws(() => wizard.account({username: "alice", password, confirm: password})); + } }); test("review and installation require a plan and explicit erase confirmation", () => { const wizard = new Wizard(); diff --git a/image/library/cybexos_managed_file.py b/image/library/cybexos_managed_file.py new file mode 100644 index 00000000..9a972865 --- /dev/null +++ b/image/library/cybexos_managed_file.py @@ -0,0 +1,88 @@ +#!/usr/bin/python3 +"""Maintain vendor defaults only while their bytes still match our last write.""" +import hashlib +import json +import os +from pathlib import Path +import tempfile + + +def atomic(path, data, mode=0o600): + fd, temporary = tempfile.mkstemp(prefix='.cybexos-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.fchmod(stream.fileno(), mode) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def manage(destination, content, ledger, absent=False, mode=0o644, check=False): + """Unknown/custom files and symlinks are never adopted or overwritten. + + Persist ownership before publishing new bytes, recording both old and new + digests, so interrupted publication is recoverable on the next invocation. + """ + destination, ledger = Path(destination), Path(ledger) + previous = json.loads(ledger.read_text()) if ledger.exists() else {} + key = str(destination) + old = previous.get(key, []) + if isinstance(old, str): + old = [old] + if destination.is_symlink() or any(p.is_symlink() for p in destination.parents): + return {'changed': False, 'preserved': True} + if destination.exists() and not destination.is_file(): + return {'changed': False, 'preserved': True} + 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 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. + if current is None and old: + return {'changed': False, 'preserved': True} + changed = current != (None if absent else content) + if check: + return {'changed': changed, 'preserved': False} + ledger.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if changed and current is not None: + backup = ledger.parent / 'backups' / hashlib.sha256(key.encode()).hexdigest() / digest + backup.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if not backup.exists(): + atomic(backup, current, destination.stat().st_mode & 0o777) + previous[key] = list(dict.fromkeys(x for x in (digest, desired) if x)) + atomic(ledger, (json.dumps(previous, sort_keys=True) + '\n').encode()) + if changed: + if absent: + destination.unlink(missing_ok=True) + else: + destination.parent.mkdir(parents=True, exist_ok=True) + atomic(destination, content, mode) + previous[key] = [desired] if desired else [] + atomic(ledger, (json.dumps(previous, sort_keys=True) + '\n').encode()) + return {'changed': changed, 'preserved': False} + + +def main(): + from ansible.module_utils.basic import AnsibleModule + module = AnsibleModule(argument_spec={ + 'dest': {'type': 'path', 'required': True}, + 'content': {'type': 'str', 'default': ''}, + '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', + absent=module.params['state'] == 'absent', + mode=int(module.params['mode'], 8), check=module.check_mode) + except (OSError, ValueError) as error: + module.fail_json(msg=str(error)) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/image/live-rootfs/usr/libexec/cybexos-installer-backend b/image/live-rootfs/usr/libexec/cybexos-installer-backend index b6ac8b85..c05d2cf6 100755 --- a/image/live-rootfs/usr/libexec/cybexos-installer-backend +++ b/image/live-rootfs/usr/libexec/cybexos-installer-backend @@ -63,8 +63,11 @@ def validate_account(data, choices): encrypted = data.get('encrypted', True) if not isinstance(encrypted, bool): raise Invalid('The encryption choice is invalid.') + passwordless_wheel = data.get('passwordless_wheel', False) + if not isinstance(passwordless_wheel, bool): + raise Invalid('The sudo password choice is invalid.') return dict(username=username, keyboard=keyboard, locale=locale, timezone=timezone, - hostname=hostname, encrypted=encrypted) + hostname=hostname, encrypted=encrypted, passwordless_wheel=passwordless_wheel) def action_set(actions): @@ -78,6 +81,7 @@ def action_set(actions): def fingerprint(disk): identity = {key: disk.get(key) for key in ('name', 'path', 'size', 'serial', 'wwn', 'device_id')} + identity['partitions'] = sorted(disk.get('partitions', []), key=lambda item: item.get('path', '')) return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() @@ -139,7 +143,38 @@ def installation_status(store, run=subprocess.run): state.update(phase='failed-install', message='The installation monitor stopped unexpectedly. Anaconda may still be writing the disk. Keep this session open and inspect the installer diagnostics before restarting.') # This status reader never writes: the worker alone owns progress # and completion. Its concurrent final update must win this race. - return state + return public_status(state) + + +def public_status(state): + """Never send persisted account, disk plan, or Anaconda text to the browser.""" + allowed = ('setup', 'planning', 'review', 'failed-plan', 'installing', 'complete', 'failed-install') + phase = state.get('phase') if state.get('phase') in allowed else 'failed-install' + messages = { + 'installing': 'Anaconda is installing CybexOS. Keep this session open.', + 'complete': 'CybexOS is installed. Restart and remove the installation medium.', + 'failed-install': 'Installation did not finish. Save diagnostics and check the installer logs before restarting.', + } + result = {'phase': phase, 'message': messages.get(phase, '')} + for key in ('step', 'total'): + if type(state.get(key)) is int and 0 <= state[key] <= 100000: + result[key] = state[key] + return result + + +def diagnostics(store, run=subprocess.run): + """Export useful status without raw logs, credentials, disk IDs, or usernames.""" + state = store.read() + service = run(['systemctl', 'show', 'cybexos-installer-worker.service', + '--property=ActiveState,SubState,Result', '--no-pager'], + capture_output=True, text=True, timeout=10) + fields = {} + for line in service.stdout.splitlines(): + key, separator, value = line.partition('=') + if separator and key in ('ActiveState', 'SubState', 'Result') and re.fullmatch(r'[a-z-]{1,32}', value): + fields[key] = value + return {'schema': 1, 'installer': public_status(state), 'worker': fields, + 'backend_ready': Path('/run/anaconda/backend_ready').exists()} class Installer: @@ -153,6 +188,13 @@ class Installer: def inventory(self): return self.backend.inventory() + def rescan(self): + self.require_idle() + # Invalidate every review before the device tree is refreshed. + self.state.write({'phase': 'setup'}) + self.backend.rescan() + return self.inventory() + def keyboard(self, data): self.require_idle() layout = data.get('keyboard', '') @@ -218,7 +260,7 @@ class Installer: state.update(phase='failed-install', message='The installation worker could not start. Review its status before restarting the installer.') self.state.write(state) raise - return state + return public_status(state) def worker(self): state = self.state.read() @@ -226,10 +268,11 @@ class Installer: raise Invalid('No new confirmed installation is queued.') def progress(step, total, message): - state.update(step=step, total=total, message=message) + # Anaconda's free-form task text can contain paths or user data. + state.update(step=step, total=total) self.state.write(state) self.emit({'event': 'progress', 'phase': 'installing', 'step': step, - 'total': total, 'message': message}) + 'total': total, 'message': 'Installing CybexOS…'}) try: self.backend.run_install(state, progress, self.state) @@ -296,14 +339,34 @@ class Anaconda: if not device.get('is-disk') or device.get('protected'): continue attrs = device.get('attrs', {}) + partitions = [] + try: + report = subprocess.run(['lsblk', '--json', '--bytes', '--output', + 'PATH,SIZE,TYPE,FSTYPE', '--', device['path']], + check=True, capture_output=True, text=True, timeout=8) + def visit(nodes): + for node in nodes: + if node.get('type') == 'part': + partitions.append({'path': node.get('path', ''), 'size': node.get('size', 0), + 'filesystem': node.get('fstype') or ''}) + visit(node.get('children', [])) + visit(json.loads(report.stdout).get('blockdevices', [])) + except (OSError, ValueError, TypeError, subprocess.SubprocessError): + # Anaconda remains the authority for whether this disk is safe to select. + pass disks.append(dict(name=name, path=device['path'], size=device['size'], model=attrs.get('model') or device.get('description') or name, serial=attrs.get('serial', ''), wwn=attrs.get('wwn', ''), - device_id=device.get('device-id', ''), removable=device.get('removable', False))) + device_id=device.get('device-id', ''), removable=device.get('removable', False), + partitions=partitions)) localization = self.proxy('Localization') keyboards = plain(localization.GetKeyboardLayouts()) keyboard_choices = [{'id': item['layout-id'], 'label': item['description']} for item in keyboards] - locales = list(localization.GetCommonLocales()) + # Common locales are only Anaconda's short list of popular choices. + # Enumerate its supported languages and regional locales so installed + # translations such as Dutch remain selectable. + locales = sorted({locale for language in localization.GetLanguages() + for locale in localization.GetLocales(language)}) if localization.Language and localization.Language not in locales: locales.append(localization.Language) timezone = self.proxy('Timezone') @@ -338,6 +401,9 @@ class Anaconda: initialization.DrivesToClear = [] initialization.DevicesToClear = [] + def rescan(self): + self.task('Storage', self.proxy('Storage').ScanDevicesWithTask(), timeout=300) + def keyboard(self, layout): localization = self.proxy('Localization') localization.XLayouts = [layout] @@ -441,10 +507,10 @@ def check_live_environment(require_backend=True): def main(): os.umask(0o077) command = sys.argv[1] if len(sys.argv) == 2 else '' - if command not in ('inventory', 'keyboard', 'plan', 'install', 'status', 'geolocate', 'reset', 'advanced', + if command not in ('inventory', 'rescan', 'diagnostics', 'keyboard', 'plan', 'install', 'status', 'geolocate', 'reset', 'advanced', 'worker', 'reboot'): raise Invalid('Unknown installer request.') - check_live_environment(require_backend=command not in ('status', 'reboot')) + check_live_environment(require_backend=command not in ('status', 'diagnostics', 'reboot')) RUNTIME.mkdir(mode=0o700, parents=True, exist_ok=True) # Serialize planning and commit. Status remains readable during installation, # and a slow geolocation lookup, which changes no selection, never blocks setup. @@ -456,6 +522,8 @@ def main(): raise Invalid('Another installer operation is still running.') from None if command == 'status': return installation_status(State()) + if command == 'diagnostics': + return diagnostics(State()) if command == 'reboot': if State().read().get('phase') != 'complete': raise Invalid('Wait for installation to finish before restarting.') @@ -475,6 +543,8 @@ def main(): installer = Installer(Anaconda(), State(), emit) if command == 'inventory': return installer.inventory() + if command == 'rescan': + return installer.rescan() if command == 'geolocate': return installer.geolocate() if command in ('reset', 'advanced'): diff --git a/image/live-rootfs/usr/libexec/cybexos-installer-target b/image/live-rootfs/usr/libexec/cybexos-installer-target index 682bfeaf..5f92e822 100755 --- a/image/live-rootfs/usr/libexec/cybexos-installer-target +++ b/image/live-rootfs/usr/libexec/cybexos-installer-target @@ -54,26 +54,38 @@ def root_is_encrypted(root, run=subprocess.run): return False -def record_autologin(root, username, autologin): +def record_install_choices(root, username, autologin, passwordless_wheel): """Keep the saved installation choices in step with the verified decision. Provisioning inside the target recorded config.yml before encryption could - be verified from outside it; only its one autologin line changes here. + be verified from outside it; update only the confirmed policy lines. """ path = root / 'etc/cybexos/config.yml' - try: - text = path.read_text() - except FileNotFoundError: - return + text = path.read_text() if not re.search(r"^primary_user: '" + re.escape(username) + "'$", text, re.MULTILINE): + raise RuntimeError('The installed configuration does not match the selected account.') + updated = text + for key, choice in (('desktop_autologin', autologin), ('passwordless_wheel', passwordless_wheel)): + pattern = rf'^{key}: (?:true|false)$' + if len(re.findall(pattern, updated, re.MULTILINE)) != 1: + raise RuntimeError(f'The installed configuration has no unique {key} choice.') + updated = re.sub(pattern, f'{key}: {str(choice).lower()}', updated, count=1, flags=re.MULTILINE) + temporary = path.with_name('.config.yml.cybexos-installer') + temporary.write_text(updated) + temporary.chmod(0o644) + temporary.replace(path) + + +def apply_sudo_policy(root, passwordless_wheel): + path = root / 'etc/sudoers.d/10-wheel-nopasswd' + if not passwordless_wheel: + path.unlink(missing_ok=True) return - updated = re.sub(r'^desktop_autologin: (?:true|false)$', - 'desktop_autologin: ' + ('true' if autologin else 'false'), text, count=1, flags=re.MULTILINE) - if updated != text: - temporary = path.with_name('.config.yml.cybexos-installer') - temporary.write_text(updated) - temporary.chmod(0o644) - temporary.replace(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name('.10-wheel-nopasswd.cybexos-installer') + temporary.write_text('%wheel ALL=(ALL:ALL) NOPASSWD: ALL\n') + temporary.chmod(0o440) + temporary.replace(path) def finalize(root, policy, encrypted): @@ -83,6 +95,9 @@ def finalize(root, policy, encrypted): raise RuntimeError('Installation policy does not name a valid account.') if not isinstance(account.get('encrypted'), bool) or not isinstance(encrypted, bool): raise RuntimeError('Installation policy does not name a valid encryption choice.') + if type(account.get('passwordless_wheel', False)) is not bool: + raise RuntimeError('Installation policy does not name a valid sudo choice.') + passwordless_wheel = account.get('passwordless_wheel', False) users = [line.split(':') for line in (root / 'etc/passwd').read_text().splitlines()] if not any(row[0] == username and 1000 <= int(row[2]) < 65534 for row in users): raise RuntimeError('The installed administrator account is missing.') @@ -97,7 +112,8 @@ def finalize(root, policy, encrypted): path.write_text(json.dumps({'version': 1, 'user': username, 'autologin': autologin, 'live': False}) + '\n') path.chmod(0o644) - record_autologin(root, username, autologin) + record_install_choices(root, username, autologin, passwordless_wheel) + apply_sudo_policy(root, passwordless_wheel) # The prepared live /etc/sddm.conf can be copied with the live filesystem. # The installed boot must regenerate it from its own verified policy. (root / 'etc/sddm.conf').unlink(missing_ok=True) @@ -107,6 +123,7 @@ def finalize(root, policy, encrypted): record.parent.mkdir(parents=True, exist_ok=True) record.write_text(json.dumps({'username': username, 'encrypted': encrypted, 'autologin': autologin, + 'passwordless_wheel': passwordless_wheel, 'passwordsInitiallyShared': True, 'keyring': 'encrypted-boot-passphrase-or-prompt'}) + '\n') record.chmod(0o644) diff --git a/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks b/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks index a032c24b..a73ee887 100644 --- a/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks +++ b/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks @@ -5,6 +5,10 @@ if getent passwd liveuser >/dev/null; then userdel --remove liveuser fi rm -f /etc/sudoers.d/cybexos-live +# The live filesystem can contain the image build's wheel policy. Every fresh +# target starts with password-required sudo; the confirmed guided choice is +# applied by the target helper after offline provisioning. +rm -f /etc/sudoers.d/10-wheel-nopasswd rm -f /etc/polkit-1/rules.d/49-cybexos-live.rules rm -f /var/lib/AccountsService/users/liveuser rm -f /etc/systemd/system/multi-user.target.wants/cybexos-live.service diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html b/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html index 1832b9c9..c2d2fe34 100644 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html +++ b/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html @@ -36,6 +36,8 @@Use at least 12 characters. Your chosen layout will also be used at the boot unlock screen.
+ +For your security, sudo asks for your account password by default. Enable this only if you want passwordless administrator commands.
Choose a disk for CybexOS. The entire selected disk will be erased.