From 73fdccf7c75d73b3efd65450480540f28648851e Mon Sep 17 00:00:00 2001 From: peterholko Date: Tue, 1 Sep 2026 22:22:09 -0700 Subject: [PATCH 1/6] Add kids mode: ask who the computer is for and install the child profile The configurator asks "Who is this computer for? Me / Child / Another owner" before the keyboard. Child takes a kid password and a parent password through the shared setup form, masks both in the summary, and hands the orchestrator the parent hash as root_enc_password with sudo: false plus a parent_encryption_password for a second LUKS slot; the context records the profile, omarchy-apply-system gets --profile child, install/omarchy-child.packages joins the runtime package list, and an add_parent_disk_key phase adds the parent's key. The builder vendors the child package list, tolerating one that holds only comments, and omarchy-iso-test --child drives a child install with GUEST_PARENT_PASSWORD. "Me" and "Another owner" keep today's flow. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +- bin/omarchy-iso-test | 85 ++++++-- builder/build-iso.sh | 18 ++ configs/airootfs/root/configurator | 143 +++++++++----- .../share/omarchy-iso/orchestrator/context.py | 13 ++ .../share/omarchy-iso/orchestrator/main.py | 4 + .../omarchy-iso/orchestrator/phases_impl.py | 96 ++++++++- test/unit/test_child_profile.py | 186 ++++++++++++++++++ 8 files changed, 478 insertions(+), 71 deletions(-) create mode 100644 test/unit/test_child_profile.py diff --git a/README.md b/README.md index 1ec23ea0..86f1b35e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ These are the configurator's own output files, so the way to get a starting set | File | Required | Purpose | |------|----------|---------| | `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard | -| `user_credentials.json` | Yes | Username and password hash | +| `user_credentials.json` | Yes | Username and password hash; on a child install also the parent hash as `root_enc_password`, `sudo: false`, and `parent_encryption_password` when encrypted | | `user_full_name.txt` | No | Git full name | | `user_email_address.txt` | No | Git email | | `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false | @@ -50,6 +50,8 @@ These are the configurator's own output files, so the way to get a starting set Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. +A child install (Omarchy's kids mode, what the interactive wizard's first question calls _Child_) is selected by `"profile": "child"` inside `omarchy_install` in `user_configuration.json`. Its credentials carry two passwords: the user's hash is the kid password, `root_enc_password` is the parent password's hash, the user has `"sudo": false` so it stays out of `wheel`, and on an encrypted install `encryption_password` (the kid's) formats the disk while `parent_encryption_password` is added as a second key. The installed system records the profile in `/etc/omarchy/profile` and keeps root for the parent through `omarchy-parent`. + Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption. `authorized_keys` is the same file sshd reads — copy your own or write one key per line: diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 8a0677f1..20a5d44a 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -14,6 +14,8 @@ # Usage: omarchy-iso-test [release/omarchy.iso] [options] # # --encrypt Drive the encrypted install flow (default: unencrypted) +# --child Answer "Child" at the first question: a kid password and +# a parent password, sudo on the parent one # --provision Drive the deferred-provisioning install flow: no user during install, the # first boot runs omarchy-provision-owner and creates it there # --reuse-base Skip the install phase if a base image already exists @@ -49,11 +51,15 @@ SYNC_ALL=false GUEST_USER="omarchy" GUEST_PASSWORD="omarchy" +# A child run types this at the parent prompts; it is then what sudo asks for. +GUEST_PARENT_PASSWORD="omarchy-parent" +CHILD=false GUEST_HOSTNAME="omarchy-test" while (($#)); do case "$1" in --encrypt) ENCRYPT=true ;; + --child) CHILD=true ;; --provision) PROVISION=true ;; --reuse-base) REUSE_BASE=true ;; --install-only) INSTALL_ONLY=true ;; @@ -70,6 +76,21 @@ while (($#)); do shift done +# sudo asks for the parent password on a child install and the account's own +# otherwise; every privileged call the harness makes goes through this one. +if $CHILD; then + GUEST_SUDO_PASSWORD="$GUEST_PARENT_PASSWORD" +else + GUEST_SUDO_PASSWORD="$GUEST_PASSWORD" +fi + +# "Another owner" installs the default profile, so a child run cannot also +# defer provisioning. +if $CHILD && $PROVISION; then + echo "--child cannot be combined with --provision" >&2 + exit 2 +fi + if [[ -z $SYNC_DIR && -f ${OMARCHY_PATH:-}/test/acceptance ]]; then SYNC_DIR="$OMARCHY_PATH" fi @@ -139,7 +160,7 @@ capture_console() { stop_vm() { vm_running || return 0 - if ! ssh_guest "echo $GUEST_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1; then + if ! ssh_guest "echo $GUEST_SUDO_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1; then qmp '"system_powerdown"' >/dev/null fi @@ -618,16 +639,16 @@ drive_configurator() { capture_console "success-installer-00-greeter" press ret - # Keyboard is the first screen. Ctrl+C there is the hidden entry into deferred provisioning - # mode; a normal install just selects the layout. - wait_for_screen "keyboard layout" 300 - capture_console "success-installer-01-keyboard-layout" + # "Who is this computer for?" is the first screen: Me is preselected, Child + # one row down, Another owner two. + wait_for_screen "computer for" 300 + capture_console "success-installer-01-computer-for" if $PROVISION; then - press ctrl-c # arm "prepare for another owner" - wait_for_screen "another owner" 30 - capture_console "success-installer-02-prepare-confirm" - press ret # "Yes, prepare for another owner" is the affirmative default + press down + press down + capture_console "success-installer-02-another-owner" + press ret # Jumps straight to disk selection, then the overwrite confirm. wait_for_screen "install disk" 60 @@ -651,6 +672,14 @@ drive_configurator() { return 0 fi + if $CHILD; then + press down + capture_console "success-installer-01-computer-for-child" + fi + press ret + + wait_for_screen "keyboard layout" 120 + capture_console "success-installer-02-keyboard-layout" press ret # English (US) is preselected # Normal flow: user step comes before disk selection. @@ -669,6 +698,20 @@ drive_configurator() { capture_console "success-installer-04-password-confirmation" press ret + if $CHILD; then + # The parent password has its own screen; its confirm names it, which tells + # it apart from the kid's confirm that was on screen a moment before. + wait_for_screen "Parent password" 60 + type_text "$GUEST_PARENT_PASSWORD" + capture_console "success-installer-04a-parent-password" + press ret + + wait_for_screen "Must match the parent" 60 + type_text "$GUEST_PARENT_PASSWORD" + capture_console "success-installer-04b-parent-password-confirmation" + press ret + fi + wait_for_screen "Full name" 60 type_text "Omarchy Test" capture_console "success-installer-05-full-name" @@ -754,6 +797,17 @@ drive_provision_owner() { type_text "$GUEST_PASSWORD" press ret + if $CHILD; then + wait_for_screen "Parent password" 60 + type_text "$GUEST_PARENT_PASSWORD" + capture_console "success-provision-03a-parent-password" + press ret + + wait_for_screen "Must match the parent" 60 + type_text "$GUEST_PARENT_PASSWORD" + press ret + fi + wait_for_screen "Full name" 60 type_text "Omarchy Test" press ret @@ -883,8 +937,8 @@ bootstrap_ssh() { mkdir -p ~/.ssh && chmod 700 ~/.ssh echo "$(cat "$SSH_KEY.pub")" >>~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys -echo "$GUEST_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp -echo "$GUEST_PASSWORD" | sudo -S systemctl enable --now sshd.service +echo "$GUEST_SUDO_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp +echo "$GUEST_SUDO_PASSWORD" | sudo -S systemctl enable --now sshd.service EOF (cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) & @@ -1054,9 +1108,9 @@ install_phase() { capture_console "success-provision-09-handoff" fi - ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \ + ssh_guest "echo $GUEST_SUDO_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \ >"$RUN_DIR/omarchy-install-timing.json" 2>/dev/null || true - ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \ + ssh_guest "echo $GUEST_SUDO_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \ >"$RUN_DIR/pacman.log" 2>/dev/null || true log "Installed system is up. Saving base image." @@ -1101,11 +1155,12 @@ acceptance_phase() { # finished product, never a dev-linked checkout. log "Running acceptance suite" ssh_guest "OMARCHY_PATH=/usr/share/omarchy OMARCHY_ACCEPTANCE_DIR=/tmp/omarchy-acceptance \ - OMARCHY_ACCEPTANCE_SUDO_PASSWORD=$GUEST_PASSWORD bash .local/share/omarchy/test/acceptance" || status=$? + OMARCHY_ACCEPTANCE_SUDO_PASSWORD=$GUEST_SUDO_PASSWORD OMARCHY_ACCEPTANCE_USER_PASSWORD=$GUEST_PASSWORD \ + bash .local/share/omarchy/test/acceptance" || status=$? log "Collecting artifacts into $RUN_DIR" ssh_guest "tar -C /tmp -cf - omarchy-acceptance 2>/dev/null" | tar -C "$RUN_DIR" -xf - || true - ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install.log 2>/dev/null" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true + ssh_guest "echo $GUEST_SUDO_PASSWORD | sudo -S cat /var/log/omarchy-install.log 2>/dev/null" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true if ((status == 0)); then capture_console "success-acceptance-final" else diff --git a/builder/build-iso.sh b/builder/build-iso.sh index db8daa2c..65e68469 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -140,6 +140,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64" # pulls the published omarchy* from the network mirror like any other package. if [[ -d /omarchy-source ]]; then base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages) + child_pkg_list=/omarchy-source/install/omarchy-child.packages setup_form=/omarchy-source/install/provisioning/setup-form.sh else # Pull the same package lists out of the freshly-downloaded Omarchy runtime @@ -162,12 +163,25 @@ else # actionable error below. bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh + # The child profile's list, tolerating a runtime that predates it the same + # way: a missing member must not abort the build. + bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-child.packages 2>/dev/null || true + child_pkg_list=/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-child.packages fi mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages" +# What a child install (kids mode) adds on top of the base list. The +# orchestrator reads the vendored copy, so a runtime without one gets an +# empty list rather than a missing file. +if [[ -f $child_pkg_list ]]; then + cp "$child_pkg_list" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-child.packages" +else + echo "# No child package list in this runtime." >"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-child.packages" +fi + # The configurator's setup form comes from the runtime this ISO bundles, so the # installer and the first-boot setup that finishes a deferred install can never # disagree. A runtime predating the split ships no such file, which would leave @@ -193,6 +207,10 @@ mapfile -t all_packages < <( { cat "$build_cache_dir/packages.x86_64" grep -hv '^#\|^$' "${base_pkg_lists[@]}" + # The child list is comment-only until the child app set lands, and grep + # exits 1 when it selects nothing; under set -e that would end this group + # here and silently drop every list after it from the mirror. + grep -hv '^#\|^$' "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-child.packages" || true grep -hv '^#\|^$' /builder/archinstall.packages # Always include the selected Omarchy packages so the target install can # find the runtime and companion packages in the offline mirror. diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 007b9331..9df60eb5 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -201,34 +201,56 @@ notice() { echo } -# STEP 1: KEYBOARD LAYOUT +# STEP 0: WHO IS THIS COMPUTER FOR -keyboard_form() { - # Ctrl+C is the hidden entry into deferred provisioning, offered on every - # visit: the user step unwinds back here and nothing is on disk yet. Callers - # re-check defer_provisioning after this returns. +# The first question. Me and Child pick the install profile; Another owner +# defers every personal question to the machine's first boot (what Ctrl+C on +# the keyboard screen used to arm). Arrow keys work under any layout, so it +# can come before the keyboard is chosen. +computer_for_form() { local status while true; do clear_logo echo say "Let's setup your machine..." - say --foreground 8 "Press Ctrl+C to prepare this machine for another owner." echo - omarchy_prompt_keyboard && status=0 || status=$? + omarchy_prompt_computer_for && status=0 || status=$? ((status == 0)) && break # Esc means "back", and nothing precedes the first screen, so re-ask. ((status == OMARCHY_FORM_BACK)) && continue + abort + done - if ((status == OMARCHY_FORM_SIGNAL)); then - if confirm_prepare_for_another_owner; then - defer_provisioning=true - return 0 - fi - continue # declined: back to the keyboard picker - fi + case $computer_for in + child) + profile="child" + child_install=true + ;; + other) + defer_provisioning=true + ;; + esac +} + +# STEP 1: KEYBOARD LAYOUT + +keyboard_form() { + local status + + while true; do + clear_logo + echo + say "Let's setup your keyboard..." + echo + + omarchy_prompt_keyboard && status=0 || status=$? + ((status == 0)) && break + + # Esc means "back", and the first question is a one-time choice, so re-ask. + ((status == OMARCHY_FORM_BACK)) && continue abort done @@ -239,19 +261,6 @@ keyboard_form() { fi } -# Ctrl+C on the first screen offers to prepare the machine for another owner. -# Confirm it before committing, so an accidental Ctrl+C doesn't silently switch -# the whole install. -confirm_prepare_for_another_owner() { - clear_logo - echo - say "This prepares the machine for another owner." - say --foreground 8 "The system installs now, but setup is delayed until first boot." - echo - gum confirm --affirmative "Yes, prepare for another owner" --negative "No, keep setting up" \ - "Prepare this machine for another owner?" -} - # The user step. Deferred-provisioning installs skip it entirely — the machine's first owner # picks their keyboard and creates their user in the first-boot setup, so the # operator sets nothing user-specific. @@ -263,38 +272,58 @@ user_form() { # (Ctrl+C). Both non-zero cases unwind to user_step, which decides what they # mean here; the form itself never aborts. omarchy_prompt_username || return $? - omarchy_prompt_password || return $? - # Hash the password with SHA-512 crypt ($6$), which is what openssl passwd offers - password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin) + if $child_install; then + # A child install: the kid password logs in, unlocks, and opens the disk; + # the parent password is root's, what sudo and system prompts ask for, and + # opens the disk too so a parent can always get in. + omarchy_prompt_password kid || return $? + password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin) + + step "Let's set the parent password..." + say "sudo, updates, installs, and system prompts ask for this one; keep it from the kid." + echo + omarchy_prompt_parent_password || return $? + parent_password_hash=$(printf '%s' "$parent_password" | openssl passwd -6 -stdin) + else + omarchy_prompt_password || return $? + + # Hash the password with SHA-512 crypt ($6$), which is what openssl passwd offers + password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin) + fi omarchy_prompt_identity || return $? omarchy_prompt_hostname || return $? omarchy_prompt_timezone || return $? } -# Returns 0 on confirmed account details, or as soon as the keyboard screen -# arms deferred provisioning. +# Returns 0 on confirmed account details. user_step() { local status + # Fixed-width masks: one asterisk per character would put the passwords' + # lengths on the screen. + local mask="********" password_label="Password" parent_row="" + if $child_install; then + password_label="Kid password" + parent_row="Parent password,$mask"$'\n' + fi while true; do user_form && status=0 || status=$? if ((status != 0)); then # Esc unwinds to the start of the form. Ctrl+C has no side channel in the - # user step (only the keyboard and disk screens give it a meaning), so it - # keeps its long-standing behaviour of ending the install. + # user step (only the disk screen gives it a meaning), so it keeps its + # long-standing behaviour of ending the install. ((status == OMARCHY_FORM_BACK)) || abort keyboard_form - $defer_provisioning && return 0 continue fi # Add manual padding since gum table -p doesn't respect padding echo -e "Field,Value Username,$username -Password,$(printf "%${#password}s" | tr ' ' '*') -Full name,${full_name:-[Skipped]} +$password_label,$mask +${parent_row}Full name,${full_name:-[Skipped]} Email address,${email_address:-[Skipped]} Hostname,$hostname Timezone,$timezone @@ -306,7 +335,6 @@ Keyboard,$keyboard" | break else keyboard_form - $defer_provisioning && return 0 fi done } @@ -458,6 +486,19 @@ _EOF_ password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa) username_escaped=$(echo -n "$username" | jq -Rsa) + # On a child install root takes the parent hash, the kid account is created + # without sudo (so archinstall leaves wheel alone; omarchy-parent apply + # grants it sudo explicitly in the chroot), and the parent passphrase rides + # along so the orchestrator can add it as a second LUKS key. + local root_hash_escaped="$password_hash_escaped" sudo_flag="true" parent_encryption_line="" + if $child_install; then + root_hash_escaped=$(echo -n "$parent_password_hash" | jq -Rsa) + sudo_flag="false" + if [[ $encrypt_installation == true ]]; then + parent_encryption_line=" \"parent_encryption_password\": $(echo -n "$parent_password" | jq -Rsa)," + fi + fi + local credentials_encryption_line="" if [[ $encrypt_installation == true ]]; then credentials_encryption_line=" \"encryption_password\": $password_escaped," @@ -466,12 +507,13 @@ _EOF_ cat <<-_EOF_ >user_credentials.json { $credentials_encryption_line - "root_enc_password": $password_hash_escaped, +$parent_encryption_line + "root_enc_password": $root_hash_escaped, "users": [ { "enc_password": $password_hash_escaped, "groups": [], - "sudo": true, + "sudo": $sudo_flag, "username": $username_escaped } ] @@ -801,6 +843,7 @@ run_partition_execute() { "custom_commands": [], "omarchy_install": { "mode": "protected", + "profile": "$profile", "target_mount": "/mnt", "boot": { "esp_mount": "$esp_mount_in_target", @@ -987,8 +1030,8 @@ confirm_disk_overwrite() { # Decide what and how to install: sets install_target (full_disk|free_space), # install_target and encrypt_installation. Nothing is written to disk yet — # the user step runs between this and the execution below. (deferred provisioning never -# reaches here; it's armed by Ctrl+C on the keyboard screen and goes straight to -# disk selection.) +# reaches here; "Another owner" at the first question goes straight to disk +# selection.) select_installation() { local full_disk_only @@ -1030,9 +1073,13 @@ select_installation() { done } -# STEP 1: KEYBOARD (Ctrl+C here arms deferred provisioning and jumps to disk selection) +# STEP 0: WHO IS THIS COMPUTER FOR ("Another owner" jumps to disk selection) defer_provisioning=false +profile="default" +child_install=false +parent_password="" +parent_password_hash="" install_target="full_disk" # Let the live VT reach its settled width before the first screen so the logo @@ -1041,12 +1088,13 @@ wait_for_stable_terminal greeter -keyboard_form +computer_for_form -# Normal install: keyboard (loaded above, so the LUKS passphrase is typed under -# the right layout) → user → disk → install mode. The user step can unwind to -# the keyboard screen and arm deferral there, hence the second check. +# Normal install: keyboard (loaded first, so the LUKS passphrase is typed under +# the right layout) → user → disk → install mode. "Another owner" skips both +# personal steps. if ! $defer_provisioning; then + keyboard_form user_step fi @@ -1139,6 +1187,7 @@ cat <<-_EOF_ >user_configuration.json "custom_commands": [], "omarchy_install": { "mode": "full_disk", + "profile": "$profile", "defer_provisioning": $defer_provisioning, "target_mount": "/mnt", "boot": { diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 9b5c50ff..1fe577db 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -12,6 +12,9 @@ from typing import Any +PROFILES = frozenset({"default", "child"}) + + @dataclass class InstallContext: config_path: Path @@ -27,6 +30,10 @@ class InstallContext: arch_config_path: Path omarchy_install: dict[str, Any] defer_provisioning: bool = False + # The install profile: "default", or "child" for kids mode, which keys + # the disk to a kid and a parent password and has the runtime keep root + # for the parent. Recorded on the target by omarchy-apply-system --profile. + profile: str = "default" target: Path = Path("/mnt") omarchy_path: Path = Path("/usr/share/omarchy") @@ -58,6 +65,11 @@ def from_env(cls) -> "InstallContext": defer_provisioning = bool(omarchy_install.get("defer_provisioning")) or defer_provisioning_marker is not None omarchy_install["defer_provisioning"] = defer_provisioning + profile = str(omarchy_install.get("profile") or "default") + if profile not in PROFILES: + raise RuntimeError(f"omarchy_install.profile must be one of {sorted(PROFILES)}, not {profile!r}") + omarchy_install["profile"] = profile + if creds_path.exists(): user_credentials = json.loads(creds_path.read_text()) elif defer_provisioning: @@ -114,6 +126,7 @@ def from_env(cls) -> "InstallContext": arch_config_path=arch_config_path, omarchy_install=omarchy_install, defer_provisioning=defer_provisioning, + profile=profile, state_dir=state_dir, ) disk_config = user_configuration.get("disk_config", {}) diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index 9b6ebf5b..2635c3b2 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -32,6 +32,7 @@ def build_phases(ctx: InstallContext): prepare_live, prepare_install_target, arch_install_system, + add_parent_disk_key, configure_hibernation, run_system_finalizer, stage_provisioning_state, @@ -49,6 +50,9 @@ def build_phases(ctx: InstallContext): ("Preparing live environment", prepare_live), ("Preparing install target", prepare_install_target), ("Installing Arch + Omarchy", arch_install_system), + # Child installs only: the parent password becomes a second LUKS key, + # while the kid's passphrase from the format can still open the add. + ("Adding the parent disk key", add_parent_disk_key), ("Configuring hibernation", configure_hibernation), ("Configuring system", run_system_finalizer), # Before finalize_limine_boot: the deferred-provisioning cryptkey drop-in and keyfile diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index 6d486245..e350beb8 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -723,10 +723,18 @@ def _unmask_mkinitcpio_pacman_hooks( info(f"warning: failed to restore pacman hook mask for {name}: {exc}") +# Where build-iso.sh vendors the runtime's package lists and setup form. +ISO_SHARE = Path("/usr/share/omarchy-iso") + + def _runtime_package_list(ctx: InstallContext) -> list[str]: """Selected Omarchy runtime package + every package in the ISO-bundled - base package list that isn't already installed early.""" - base_pkgs_file = Path("/usr/share/omarchy-iso/omarchy-base.packages") + base package list that isn't already installed early. A child install adds + the runtime's child list on top; build-iso.sh vendors it beside the base + list and carries its packages in the offline mirror.""" + pkgs_files = [ISO_SHARE / "omarchy-base.packages"] + if ctx.profile == "child": + pkgs_files.append(ISO_SHARE / "omarchy-child.packages") pkgs = [_omarchy_runtime_package()] already_installed = set(_early_packages()) | { _omarchy_runtime_package(), @@ -736,15 +744,85 @@ def _runtime_package_list(ctx: InstallContext) -> list[str]: "omarchy-settings", "omarchy-nvim", } - for raw in base_pkgs_file.read_text().splitlines(): - s = raw.strip() - if not s or s.startswith("#"): - continue - if s not in already_installed and s not in pkgs: - pkgs.append(s) + for pkgs_file in pkgs_files: + for raw in pkgs_file.read_text().splitlines(): + s = raw.strip() + if not s or s.startswith("#"): + continue + if s not in already_installed and s not in pkgs: + pkgs.append(s) return pkgs +# ───────────────────────────────────────────────────────────────────────────── +# add_parent_disk_key: on an encrypted child install, key the LUKS volume to the +# parent password as well as the kid's. That second slot is what lets a parent +# boot a machine whose kid has forgotten their password and reset it from +# inside. The kid's passphrase (the one the disk was formatted with) unlocks +# the volume for the add; both travel as root-only key files, never argv. +# ───────────────────────────────────────────────────────────────────────────── + +def _luks_partition(ctx: InstallContext) -> str: + storage = _storage_intent(ctx) + if storage.get("luks_uuid"): + return f"/dev/disk/by-uuid/{storage['luks_uuid']}" + + # Full-disk installs let archinstall pick the partition; find the one it + # formatted on the install disk. + modifications = (ctx.user_configuration.get("disk_config") or {}).get("device_modifications") or [] + disks = [m.get("device") for m in modifications if m.get("device")] + res = capture(["blkid", "-t", "TYPE=crypto_LUKS", "-o", "device"]) + candidates = [line.strip() for line in res.stdout.splitlines() if line.strip()] + if disks: + candidates = [c for c in candidates if any(c.startswith(d) for d in disks)] + if len(candidates) != 1: + raise RuntimeError(f"expected one LUKS partition on {disks or 'the install disk'}, found {candidates}") + return candidates[0] + + +def _write_key_file(ctx: InstallContext, name: str, passphrase: str) -> Path: + path = ctx.state_dir / name + path.touch(mode=0o600, exist_ok=True) + path.chmod(0o600) + # Byte-for-byte the slot passphrase: no trailing newline. + path.write_text(passphrase) + return path + + +def add_parent_disk_key(ctx: InstallContext) -> None: + if ctx.profile != "child" or ctx.defer_provisioning or not _provision_install_encrypted(ctx): + return + + kid = _provision_encryption_password(ctx) + parent = ctx.user_credentials.get("parent_encryption_password") + if not kid or not parent: + raise RuntimeError( + "child install on an encrypted disk needs both encryption_password and " + "parent_encryption_password in user_credentials.json to key the disk to the parent" + ) + + device = _luks_partition(ctx) + kid_file = _write_key_file(ctx, "luks-kid-key", kid) + parent_file = _write_key_file(ctx, "luks-parent-key", parent) + try: + already = subprocess.run( + ["cryptsetup", "open", "--test-passphrase", "--key-file", str(parent_file), device], + capture_output=True, + ) + if already.returncode == 0: + info("› the parent password already unlocks the disk") + return + + info("› adding the parent password as a second disk key") + subprocess.run( + ["cryptsetup", "luksAddKey", "--key-file", str(kid_file), device, str(parent_file)], + check=True, + ) + finally: + kid_file.unlink(missing_ok=True) + parent_file.unlink(missing_ok=True) + + # ───────────────────────────────────────────────────────────────────────────── # Install intent helpers: normalize the Omarchy-specific part of the # configurator JSON so full-disk and pre-mounted installs feed the same boot @@ -1088,6 +1166,7 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str "OMARCHY_PATH=/usr/share/omarchy", "OMARCHY_INSTALL=/usr/share/omarchy/install", f"OMARCHY_INSTALL_USER={ctx.username}", + f"OMARCHY_INSTALL_PROFILE={ctx.profile}", f"OMARCHY_START_TIME={omarchy_start_time}", f"OMARCHY_START_EPOCH={omarchy_start_epoch}", f"OMARCHY_USER_NAME={ctx.full_name}", @@ -1134,6 +1213,7 @@ def run_system_finalizer(ctx: InstallContext) -> None: cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"] else: cmd = ["/usr/bin/omarchy-apply-system", "--install-user", ctx.username, "--first-install"] + cmd += ["--profile", ctx.profile] _mask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS) try: diff --git a/test/unit/test_child_profile.py b/test/unit/test_child_profile.py new file mode 100644 index 00000000..8798f0fc --- /dev/null +++ b/test/unit/test_child_profile.py @@ -0,0 +1,186 @@ +"""Unit tests for the child install profile (Omarchy's kids mode) in the +orchestrator: the profile read from the configurator's JSON, the child package +list on top of the base set, the --profile handoff to omarchy-apply-system, and +the parent password added as a second LUKS key.""" + +import json +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso")) + +sys.modules.setdefault( + "orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter") +) + +from orchestrator import phases_impl # noqa: E402 +from orchestrator.context import InstallContext # noqa: E402 + + +class ChildProfileContextTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + self.env = { + "OMARCHY_INSTALL_CONFIG": str(self.dir / "user_configuration.json"), + "OMARCHY_INSTALL_CREDS": str(self.dir / "user_credentials.json"), + "OMARCHY_INSTALL_STATE_DIR": str(self.dir / "state"), + } + + def from_env(self, config, creds, **extra_env): + (self.dir / "user_configuration.json").write_text(json.dumps(config)) + (self.dir / "user_credentials.json").write_text(json.dumps(creds)) + with mock.patch.dict(os.environ, {**self.env, **extra_env}, clear=False): + return InstallContext.from_env() + + def config(self, profile=None, **omarchy_install): + block = {"mode": "full_disk", "target_mount": "/mnt", **omarchy_install} + if profile is not None: + block["profile"] = profile + return {"omarchy_install": block, "disk_config": {"config_type": "default_layout"}} + + def creds(self, **extra): + return { + "root_enc_password": "$6$parent", + "users": [{"username": "kid", "enc_password": "$6$kid", "groups": [], "sudo": False}], + **extra, + } + + def test_profile_defaults_when_absent(self): + ctx = self.from_env(self.config(), self.creds()) + self.assertEqual(ctx.profile, "default") + self.assertEqual(ctx.omarchy_install["profile"], "default") + + def test_child_profile_is_read(self): + ctx = self.from_env(self.config(profile="child"), self.creds()) + self.assertEqual(ctx.profile, "child") + + def test_unknown_profile_is_refused(self): + with self.assertRaises(RuntimeError): + self.from_env(self.config(profile="teen"), self.creds()) + + def test_deferred_child_install_drops_the_parent_passphrase(self): + ctx = self.from_env( + self.config(profile="child", defer_provisioning=True), + self.creds(encryption_password="kid-pass", parent_encryption_password="parent-pass"), + ) + self.assertEqual(ctx.profile, "child") + self.assertEqual(ctx.user_credentials.get("users"), []) + self.assertNotIn("parent_encryption_password", ctx.user_credentials) + + +class ChildPackageListTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + share = Path(self.tmp.name) + (share / "omarchy-base.packages").write_text("# base\nfoo\nbar\n") + (share / "omarchy-child.packages").write_text("# child\nfoo\ngcompris\n") + self.share = share + + def list_for(self, profile): + ctx = types.SimpleNamespace(profile=profile) + with mock.patch.object(phases_impl, "ISO_SHARE", self.share), \ + mock.patch.object(phases_impl, "_early_packages", return_value=[]), \ + mock.patch.object(phases_impl, "_omarchy_runtime_package", return_value="omarchy"), \ + mock.patch.object(phases_impl, "_omarchy_settings_package", return_value="omarchy-settings"), \ + mock.patch.object(phases_impl, "_omarchy_nvim_package", return_value="omarchy-nvim"): + return phases_impl._runtime_package_list(ctx) + + def test_default_profile_installs_the_base_list_only(self): + self.assertEqual(self.list_for("default"), ["omarchy", "foo", "bar"]) + + def test_child_profile_adds_the_child_list_without_duplicates(self): + self.assertEqual(self.list_for("child"), ["omarchy", "foo", "bar", "gcompris"]) + + +class ProfileHandoffTest(unittest.TestCase): + def test_system_finalizer_passes_the_profile(self): + ctx = types.SimpleNamespace(profile="child", defer_provisioning=False, username="kid", target=Path("/mnt")) + with mock.patch.object(phases_impl, "_run_target_setup_command") as run, \ + mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \ + mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"): + phases_impl.run_system_finalizer(ctx) + cmd = run.call_args.args[1] + self.assertEqual(cmd[-2:], ["--profile", "child"]) + self.assertIn("--install-user", cmd) + + def test_deferred_finalizer_still_passes_the_profile(self): + ctx = types.SimpleNamespace(profile="child", defer_provisioning=True, username="", target=Path("/mnt")) + with mock.patch.object(phases_impl, "_run_target_setup_command") as run, \ + mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \ + mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"): + phases_impl.run_system_finalizer(ctx) + cmd = run.call_args.args[1] + self.assertIn("--defer-provisioning", cmd) + self.assertEqual(cmd[-2:], ["--profile", "child"]) + + +class ParentDiskKeyTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.state_dir = Path(self.tmp.name) + + def ctx(self, profile="child", encrypted=True, parent="parent-pass", kid="kid-pass", deferred=False): + storage = {"luks_uuid": "1111-2222"} if encrypted else {} + creds = {"users": [{"username": "kid"}]} + if kid: + creds["encryption_password"] = kid + if parent: + creds["parent_encryption_password"] = parent + return types.SimpleNamespace( + profile=profile, + defer_provisioning=deferred, + encrypt=encrypted, + user_credentials=creds, + user_configuration={"disk_config": {"config_type": "pre_mounted_config"}}, + omarchy_install={"mode": "protected", "storage": storage}, + state_dir=self.state_dir, + ) + + def run_phase(self, ctx, test_passphrase_rc=1): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + rc = test_passphrase_rc if "--test-passphrase" in cmd else 0 + return CompletedProcess(cmd, rc) + + with mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run): + phases_impl.add_parent_disk_key(ctx) + return calls + + def test_adds_the_parent_key_with_the_kid_passphrase_unlocking(self): + calls = self.run_phase(self.ctx()) + add = [c for c in calls if c[:2] == ["cryptsetup", "luksAddKey"]] + self.assertEqual(len(add), 1) + self.assertEqual(add[0][2:4], ["--key-file", str(self.state_dir / "luks-kid-key")]) + self.assertEqual(add[0][4], "/dev/disk/by-uuid/1111-2222") + self.assertEqual(add[0][5], str(self.state_dir / "luks-parent-key")) + self.assertFalse((self.state_dir / "luks-kid-key").exists(), "key files are removed afterwards") + self.assertFalse((self.state_dir / "luks-parent-key").exists(), "key files are removed afterwards") + + def test_is_idempotent_when_the_parent_key_already_unlocks(self): + calls = self.run_phase(self.ctx(), test_passphrase_rc=0) + self.assertFalse(any(c[:2] == ["cryptsetup", "luksAddKey"] for c in calls)) + + def test_does_nothing_outside_child_or_encrypted_installs(self): + self.assertEqual(self.run_phase(self.ctx(profile="default")), []) + self.assertEqual(self.run_phase(self.ctx(encrypted=False)), []) + self.assertEqual(self.run_phase(self.ctx(deferred=True)), []) + + def test_refuses_without_both_passphrases(self): + with self.assertRaises(RuntimeError): + self.run_phase(self.ctx(parent=None)) + + +if __name__ == "__main__": + unittest.main() From cbfea58f08391ef1e8f03369d8be097d91528bda Mon Sep 17 00:00:00 2001 From: peterholko Date: Wed, 2 Sep 2026 11:17:46 -0700 Subject: [PATCH 2/6] Put the account header back after the parent password screen The configurator printed "Let's set the parent password..." as a screen header and then asked for the name, email, hostname, and timezone under it. A fresh header follows the parent password now. Co-Authored-By: Claude Fable 5.1 --- configs/airootfs/root/configurator | 3 +++ 1 file changed, 3 insertions(+) diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 9df60eb5..378fcebf 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -285,6 +285,9 @@ user_form() { echo omarchy_prompt_parent_password || return $? parent_password_hash=$(printf '%s' "$parent_password" | openssl passwd -6 -stdin) + # The parent-password header would otherwise sit over the screens that + # follow, which are about the account again. + step "Let's finish your user account..." else omarchy_prompt_password || return $? From 9ceee41c20577da41412af30e322af25c40b8256 Mon Sep 17 00:00:00 2001 From: peterholko Date: Wed, 2 Sep 2026 15:33:54 -0700 Subject: [PATCH 3/6] Drive a child run through the parent password and the desktop Child installs close the text consoles, so the harness's console login for the SSH bootstrap, and the tty3 check after the disk unlock, never came back on a --child run. A child run now does what a parent would: it types the parent password at the disk and at SDDM, opens the terminal with Super+Return, runs the bootstrap there, and checks that SSH comes up as the kid account, which is the proof that the parent password opens the kid's session; later boots log in the same way. The kid's own password is still typed at the installer and refused by sudo in the in-guest suite. Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-iso-test | 93 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 20a5d44a..621d692a 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -15,7 +15,11 @@ # # --encrypt Drive the encrypted install flow (default: unencrypted) # --child Answer "Child" at the first question: a kid password and -# a parent password, sudo on the parent one +# a parent password, sudo on the parent one. The run then +# proves the parent password opens the disk and the login +# screen into the kid's session, and bootstraps SSH from +# the desktop's terminal, since child installs close the +# text consoles. # --provision Drive the deferred-provisioning install flow: no user during install, the # first boot runs omarchy-provision-owner and creates it there # --reuse-base Skip the install phase if a base image already exists @@ -76,6 +80,16 @@ while (($#)); do shift done +# A child run logs in with the parent password, at the disk and at SDDM: it +# must land in the kid's session (the in-guest suite then checks whose it is), +# and the kid's own password is exercised at the installer and by sudo's +# refusal of it. A "Me" run logs in with the one password there is. +if $CHILD; then + GUEST_LOGIN_PASSWORD="$GUEST_PARENT_PASSWORD" +else + GUEST_LOGIN_PASSWORD="$GUEST_PASSWORD" +fi + # sudo asks for the parent password on a child install and the account's own # otherwise; every privileged call the harness makes goes through this one. if $CHILD; then @@ -905,7 +919,7 @@ unlock_luks() { capture_console "success-first-boot-01-luks-prompt" while true; do - type_text "$GUEST_PASSWORD" + type_text "$GUEST_LOGIN_PASSWORD" if ((waited == 0)); then capture_console "success-first-boot-02-luks-passphrase" fi @@ -913,6 +927,15 @@ unlock_luks() { sleep 20 ((waited += 30)) + if $CHILD; then + # Child installs close the text consoles, so the sign that the disk + # opened is the passphrase prompt leaving the screen. + if ! ocr_screen | grep -qi "passphrase"; then + return 0 + fi + continue + fi + press ctrl-alt-f3 sleep 3 if ocr_screen | grep -qi "login:"; then @@ -930,6 +953,11 @@ unlock_luks() { # Log into a spare console TTY as the user and authorize the harness SSH key — # the same thing a person would do to give themselves remote access. bootstrap_ssh() { + if $CHILD; then + bootstrap_ssh_desktop + return + fi + log "Authorizing SSH access via console login" mkdir -p "$BASE_DIR/www" @@ -979,6 +1007,63 @@ EOF return 1 } +# A child install closes the text consoles, so the harness does what a parent +# would: log in at SDDM with the parent password (an encrypted install has +# autologged in after the disk), open the terminal with Super+Return, and run +# the bootstrap there. SSH coming up as the kid is the proof that the parent +# password opened the kid's session. +bootstrap_ssh_desktop() { + log "Authorizing SSH access from the desktop (the parent password at the login screen)" + + mkdir -p "$BASE_DIR/www" + cat >"$BASE_DIR/www/bootstrap" <>~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +echo "$GUEST_SUDO_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp +echo "$GUEST_SUDO_PASSWORD" | sudo -S systemctl enable --now sshd.service +EOF + + (cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) & + HTTP_PID=$! + + sleep 30 # first boot reaches SDDM, or the desktop on an encrypted install + local attempt + for attempt in 1 2 3; do + if ! $ENCRYPT; then + capture_console "success-first-boot-03-login-screen" + type_text "$GUEST_LOGIN_PASSWORD" + press ret + sleep 25 + fi + capture_console "success-first-boot-04-desktop" + press meta_l-ret # the terminal + sleep 8 + type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap -o /tmp/bs && bash /tmp/bs && exit" + capture_console "success-first-boot-05-bootstrap-command" + press ret + + if wait_for_ssh 120 "failure-first-boot-ssh-timeout-$attempt"; then + capture_console "success-first-boot-06-bootstrap-complete" + kill "$HTTP_PID" 2>/dev/null || true + HTTP_PID="" + local who + who=$(ssh_guest whoami 2>/dev/null | tr -d '\r\n') || who="" + if [[ $who != "$GUEST_USER" ]]; then + echo "The parent password opened a session for '${who:-}', expected the kid account '$GUEST_USER'" >&2 + return 1 + fi + log "the parent password opened the kid's session ($who)" + return 0 + fi + press escape + sleep 5 + done + + echo "Desktop bootstrap did not produce SSH access after 3 attempts" >&2 + return 1 +} + # Get a freshly booted system to a running Hyprland session, typing at the # LUKS prompt and/or SDDM greeter exactly like a user would. establish_session() { @@ -987,7 +1072,7 @@ establish_session() { sleep 10 capture_console "success-session-01-luks-prompt" until ssh_guest true 2>/dev/null; do - type_text "$GUEST_PASSWORD" + type_text "$GUEST_LOGIN_PASSWORD" if ((waited == 0)); then capture_console "success-session-02-luks-passphrase" fi @@ -1014,7 +1099,7 @@ establish_session() { if ((waited == 0)); then capture_console "success-session-01-login" fi - type_text "$GUEST_PASSWORD" + type_text "$GUEST_LOGIN_PASSWORD" if ((waited == 0)); then capture_console "success-session-02-password" fi From 09a068d30db7448e355d67306c34555d1b07fa3f Mon Sep 17 00:00:00 2001 From: peterholko Date: Wed, 2 Sep 2026 15:46:12 -0700 Subject: [PATCH 4/6] Skip the full name and email prompts on a child install Both answers only feed git identity and the compose shortcuts, and on a child install it is a parent at the keyboard, so whatever they typed would have landed in the kid's git config as the parent's identity. The child user step now goes from the parent password to the hostname, the summary drops the two rows, and the harness's child run types neither. Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-iso-test | 47 +++++++++++++++++------------- configs/airootfs/root/configurator | 20 ++++++++++--- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 621d692a..b7924fa1 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -726,15 +726,18 @@ drive_configurator() { press ret fi - wait_for_screen "Full name" 60 - type_text "Omarchy Test" - capture_console "success-installer-05-full-name" - press ret + # A child install asks for neither name nor email. + if ! $CHILD; then + wait_for_screen "Full name" 60 + type_text "Omarchy Test" + capture_console "success-installer-05-full-name" + press ret - wait_for_screen "Email address" 60 - type_text "test@omarchy.org" - capture_console "success-installer-06-email" - press ret + wait_for_screen "Email address" 60 + type_text "test@omarchy.org" + capture_console "success-installer-06-email" + press ret + fi wait_for_screen "Hostname" 60 type_text "$GUEST_HOSTNAME" @@ -822,19 +825,23 @@ drive_provision_owner() { press ret fi - wait_for_screen "Full name" 60 - type_text "Omarchy Test" - press ret + # A child install asks for neither name nor email, so a child run goes + # without the '@' keymap proof below. + if ! $CHILD; then + wait_for_screen "Full name" 60 + type_text "Omarchy Test" + press ret - wait_for_screen "Email address" 60 - # The keyboard step above applied English (UK), where '@' is Shift+' (not - # Shift+2 as on US); send that keycode so the typed email is correct under - # the new layout — itself further proof the deferred keymap took effect. - type_text "test" - press shift-apostrophe - type_text "omarchy.org" - capture_console "success-provision-04-email" - press ret + wait_for_screen "Email address" 60 + # The keyboard step above applied English (UK), where '@' is Shift+' (not + # Shift+2 as on US); send that keycode so the typed email is correct under + # the new layout — itself further proof the deferred keymap took effect. + type_text "test" + press shift-apostrophe + type_text "omarchy.org" + capture_console "success-provision-04-email" + press ret + fi # Hostname is deferred to first boot too. wait_for_screen "Hostname" 60 diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 378fcebf..4b53fdf4 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -295,7 +295,15 @@ user_form() { password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin) fi - omarchy_prompt_identity || return $? + # Full name and email only feed git identity and the compose shortcuts, and + # it is a parent at the keyboard on a child install, so an answer would be + # theirs: a child install asks for neither. + if $child_install; then + full_name="" + email_address="" + else + omarchy_prompt_identity || return $? + fi omarchy_prompt_hostname || return $? omarchy_prompt_timezone || return $? } @@ -322,13 +330,17 @@ user_step() { continue fi + # The name and email rows, unless the form never asked (a child install). + local identity_rows="" + if ! $child_install; then + identity_rows="Full name,${full_name:-[Skipped]}"$'\n'"Email address,${email_address:-[Skipped]}"$'\n' + fi + # Add manual padding since gum table -p doesn't respect padding echo -e "Field,Value Username,$username $password_label,$mask -${parent_row}Full name,${full_name:-[Skipped]} -Email address,${email_address:-[Skipped]} -Hostname,$hostname +${parent_row}${identity_rows}Hostname,$hostname Timezone,$timezone Keyboard,$keyboard" | gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" From 59dc92c26d1071e6faf0c5270b6a6adbc3f4ad4a Mon Sep 17 00:00:00 2001 From: Omabot Date: Sat, 5 Sep 2026 05:43:39 -0700 Subject: [PATCH 5/6] Bound the child LUKS unlock and stop a blank screen reading as success The child branch returned 0 whenever OCR did not read "passphrase", and ocr_screen returns nothing at all when the screendump is empty or the magick conversion fails -- so a failed read was indistinguishable from an opened disk and --child --encrypt reported green 30 seconds in, without the disk having opened. Its `continue` also skipped the waited >= 300 check, so a passphrase that never unlocks typed forever instead of failing. Both proved against the extracted function with stubbed helpers: blank OCR returned 0, and a screen still showing the prompt did not terminate. Requiring OCR to have said something keeps the author's signal while closing the blank-read case, and folding the console branch into an else lets the existing timeout cover both. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-iso-test | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index b7924fa1..0a75ba32 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -921,7 +921,7 @@ wait_for_install() { unlock_luks() { log "Typing the LUKS passphrase until boot proceeds" - local waited=0 + local waited=0 screen sleep 10 capture_console "success-first-boot-01-luks-prompt" @@ -936,17 +936,19 @@ unlock_luks() { if $CHILD; then # Child installs close the text consoles, so the sign that the disk - # opened is the passphrase prompt leaving the screen. - if ! ocr_screen | grep -qi "passphrase"; then + # opened is the passphrase prompt leaving the screen. ocr_screen says + # nothing at all when the screendump or the conversion fails, which + # would otherwise read as a prompt that has gone. + screen=$(ocr_screen) + if [[ -n $screen ]] && ! grep -qi "passphrase" <<<"$screen"; then + return 0 + fi + else + press ctrl-alt-f3 + sleep 3 + if ocr_screen | grep -qi "login:"; then return 0 fi - continue - fi - - press ctrl-alt-f3 - sleep 3 - if ocr_screen | grep -qi "login:"; then - return 0 fi if ((waited >= 300)); then From 385a0c3583fa43c6213082de0cb422ad4dc5b2c6 Mon Sep 17 00:00:00 2001 From: Omabot Date: Sat, 5 Sep 2026 05:43:45 -0700 Subject: [PATCH 6/6] Send esc, the key QEMU knows, when a desktop bootstrap attempt fails QMP rejects "escape": against a live socket, esc returns {"return": {}} and escape returns "Parameter 'data' does not accept value 'escape'". qmp() ends in `|| true` with stderr discarded, so the retry never cleared the screen and failed silently. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-iso-test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 0a75ba32..a76b301a 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -1065,7 +1065,7 @@ EOF log "the parent password opened the kid's session ($who)" return 0 fi - press escape + press esc sleep 5 done