From 86314631bacbc25128bd72f664f0cdf272d0973b Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Fri, 2 Oct 2026 20:40:15 -0400 Subject: [PATCH 1/9] Ask once for the password in a quieter, renamed Recovery step The stub's Recovery setup is now Omarchy's own step2.sh, titled with the app's name. It asks for the password once and asks one "Are you sure?", then answers bputil, bless and kmutil with the known owner and that password. kmutil reads its user name and password from its terminal and discards type-ahead, so it runs on a hidden terminal and gets each answer when its prompt appears; if it fails or stalls for a minute, the owner answers it directly. A spinner shows bputil and kmutil working, a wrong password is named plainly, and every line fits an 80-column Terminal. Engine v0.9.2-omarchy.26 carries it, and the release inputs move to it. The app passes DISTRO "Omarchy" and OMARCHY_INSTALLER_NAME. Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/src/omarchy_asahi.py | 264 +++++++++++++++++- Engine/overlay/src/omarchy_runtime.py | 1 + Engine/overlay/tests/test_omarchy_asahi.py | 196 +++++++++++++ Engine/overlay/tests/test_omarchy_runtime.py | 4 +- Engine/rebuild-python-overlay.py | 2 +- Engine/source-lock.json | 12 +- .../PinnedAsahiEngineExecutor.swift | 3 +- .../PinnedAsahiEngineExecutorTests.swift | 3 +- scripts/release-inputs-aurora.template.json | 4 +- scripts/release-inputs.template.json | 4 +- 10 files changed, 477 insertions(+), 16 deletions(-) diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index 4e07254..0b3320f 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -26,6 +26,7 @@ TARGET = "apple-silicon-full-os" MAXIMUM_PASSWORD_BYTES = 1_024 MACHINE_OWNER_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,255}$") +INSTALLER_TITLE_PATTERN = re.compile(r"^[A-Za-z0-9 ._()-]{1,64}$") PARTITION_PATTERN = re.compile(r"^disk[0-9]+s[0-9]+$") READBACK_CHUNK_BYTES = 1024 * 1024 @@ -34,6 +35,265 @@ class AsahiAdapterError(RuntimeError): pass +# The Recovery setup that the stub's "Finish Installation" app opens in +# Terminal, replacing asahi-installer's step2.sh: the installer's own name, one +# password prompt and one confirmation. bputil and bless take the owner and +# password as arguments; kmutil has no credential options, so the script +# answers its prompts on a hidden terminal. +STEP2_SCRIPT = """#!/bin/sh +# SPDX-License-Identifier: MIT +# ##TITLE##, second step. Runs in the new install's own recoveryOS. + +set -e + +VGID="##VGID##" +PREBOOT="##PREBOOT##" +OWNER="##OWNER##" + +self="$0" +cd "${self%%step2.sh}" +system_dir="$(cd ../../../; pwd)" +os_name="${system_dir##*/}" + +BOLD="$(printf '\\033[1m')" +RST="$(printf '\\033[m')" + +printf '\\033[2J\\033[H' +echo "${BOLD}##TITLE##${RST}" +echo + +bputil -d -v "$VGID" >/tmp/bp.txt + +if [ -z "$OWNER" ]; then + printf "Your macOS user name: " + read -r OWNER +fi + +if ! grep -q ': Paired' /tmp/bp.txt; then + echo "This step needs $os_name's own Recovery. Making $os_name the startup" + echo "disk so that the next start opens it." + echo + # bless takes the known owner and the password on stdin, so it asks for + # neither itself. After three failures, let bless ask on its own. + tries=0 + while :; do + printf "Password for %s: " "$OWNER" + stty -echo + read -r PASSWORD + stty echo + echo + if printf '%s\\n' "$PASSWORD" | bless --setBoot --mount "$system_dir" --user "$OWNER" --stdinpass >/tmp/bless.log 2>&1; then + break + fi + tries=$((tries + 1)) + if [ "$tries" -ge 3 ]; then + echo "macOS asks itself now. Type your user name and password." + while ! bless --setBoot --mount "$system_dir"; do + echo "That didn't work. Press Enter to try again." + read + done + break + fi + echo "That password didn't work for $OWNER. Try again." + echo + done + PASSWORD="" + echo + echo "Press Enter to shut down. Then hold the power button until you see" + echo "'Loading startup options', choose $os_name, and log in." + read + shutdown -h now + exit 1 +fi + +if ! grep -q 'one true recoveryOS' /tmp/bp.txt; then + echo "The power button was released too early to finish setting up." + echo + echo "Press Enter to shut down. Then hold the power button, without letting" + echo "go, until you see 'Loading startup options', and choose $os_name." + read + shutdown -h now + exit 1 +fi + +echo "This lets $os_name start Linux by lowering the security level of" +echo "$os_name only. ${BOLD}macOS keeps Full Security.${RST}" +echo + +# Spin after the current line until the file $1 exists, at most $2 seconds. +spin_until() { + spun=0 + printf ' ' + while [ ! -e "$1" ] && [ "$spun" -lt $(($2 * 4)) ]; do + case $((spun % 4)) in + 0) c='|' ;; + 1) c='/' ;; + 2) c='-' ;; + *) c='\\' ;; + esac + printf '\\b%s' "$c" + sleep 0.25 + spun=$((spun + 1)) + done + printf '\\b \\n' +} + +tries=0 +while :; do + printf "Password for %s: " "$OWNER" + stty -echo + read -r PASSWORD + stty echo + echo + printf "Updating %s's security settings..." "$os_name" + rm -f /tmp/bputil.status + { + # set -e would end this block before it records a failure. + status=0 + bputil -nc -v "$VGID" -u "$OWNER" -p "$PASSWORD" >/tmp/bputil.log 2>&1 || status=$? + echo "$status" >/tmp/bputil.status.new + mv /tmp/bputil.status.new /tmp/bputil.status + } & + spin_until /tmp/bputil.status 600 + if [ "$(cat /tmp/bputil.status 2>/dev/null)" = 0 ]; then + break + fi + # bputil's log opens with its own disclaimer, so don't show it here. + tries=$((tries + 1)) + echo "That password didn't work for $OWNER. Try again." + if [ "$tries" -ge 3 ]; then + echo "If the password is right, /tmp/bputil.log says what went wrong." + fi + echo +done + +if [ -e "/System/Volumes/iSCPreboot/$VGID/boot" ]; then + # An external volume: kmutil looks for AdminUserRecoveryInfo.plist in the + # wrong place. + diskutil mount "$PREBOOT" + preboot="$(diskutil info "$PREBOOT" | grep "Mount Point" | sed 's, *Mount Point: *,,')" + cp -R "$preboot/$VGID/var" "/System/Volumes/iSCPreboot/$VGID/" +fi + +echo +printf "Are you sure you want to do this? (y or n) " +read -r answer +case "$answer" in + y|Y|yes|Yes|YES) ;; + *) + echo "Omarchy's boot loader was not installed. Run this again when you're ready." + exit 1 + ;; +esac +echo +printf "Installing Omarchy's boot loader..." +# kmutil asks "are you sure" on stdin, then reads a user name and password +# from its terminal, discarding anything typed ahead. So run it on a hidden +# terminal (script) and type each answer once its prompt appears. The +# password is typed with echo off, so the log never holds it. If kmutil +# fails or asks again, stop it after a minute and let the owner answer it. +# Nothing waits on the background jobs: kmutil's status lands in a file. +# (A background job's input is /dev/null unless redirected, hence <&0.) +kmutil_log=/tmp/kmutil.log +kmutil_status=/tmp/kmutil.status +kmutil_wait_for() { + tries=0 + while ! grep -q "$1" "$kmutil_log" 2>/dev/null; do + [ ! -e "$kmutil_status" ] && [ "$tries" -lt 600 ] || return 1 + sleep 0.1 + tries=$((tries + 1)) + done +} +kmutil_ok=no +if command -v script >/dev/null 2>&1; then + rm -f "$kmutil_log" "$kmutil_status" /tmp/kmutil.pid + { + kmutil_wait_for 'enter y or n' && printf 'y\\n' && + kmutil_wait_for 'Username:' && printf '%s\\n' "$OWNER" && + kmutil_wait_for 'Password:' && printf '%s\\n' "$PASSWORD" + # Keep kmutil's input open until it exits. + tries=0 + while [ ! -e "$kmutil_status" ] && [ "$tries" -lt 700 ]; do + sleep 0.1 + tries=$((tries + 1)) + done + } 2>/dev/null | { + script -q -t 0 "$kmutil_log" kmutil configure-boot -c boot.bin --raw --entry-point 2048 --lowest-virtual-address 0 -v "$system_dir" <&0 >/dev/null 2>&1 & + echo $! >/tmp/kmutil.pid + status=0 + wait $! || status=$? + echo "$status" >"$kmutil_status.new" + mv "$kmutil_status.new" "$kmutil_status" + } & + spin_until "$kmutil_status" 60 + if [ -e "$kmutil_status" ]; then + [ "$(cat "$kmutil_status")" = 0 ] && kmutil_ok=yes + else + kill "$(cat /tmp/kmutil.pid)" 2>/dev/null || true + sleep 1 + kill -9 "$(cat /tmp/kmutil.pid)" 2>/dev/null || true + fi +fi +if [ "$kmutil_ok" != yes ]; then + echo + echo "macOS asks once more. Type y, then your user name and password." + while ! kmutil configure-boot -c boot.bin --raw --entry-point 2048 --lowest-virtual-address 0 -v "$system_dir"; do + echo "That didn't work. Press Enter to try again." + read + done +fi +PASSWORD="" + +mount -u -w "$system_dir" +if [ -e "$system_dir/.IAPhysicalMedia" ]; then + mv "$system_dir/.IAPhysicalMedia" "$system_dir/IAPhysicalMedia-disabled.plist" +fi +if [ -e "$system_dir/System/Library/CoreServices/SystemVersion-disabled.plist" ]; then + mv -f "$system_dir/System/Library/CoreServices/SystemVersion"{-disabled,}".plist" +fi + +echo +echo "${BOLD}Done.${RST} Press Enter to restart into $os_name." +read +reboot +""" + + +def _write_step2(installer): + owner = os.environ.get("OMARCHY_MACHINE_OWNER", "") + if owner and MACHINE_OWNER_PATTERN.fullmatch(owner) is None: + raise AsahiAdapterError("machine owner name is invalid for the Recovery setup") + # The app passes its configured name (Packaging/identity.conf). + title = os.environ.get("OMARCHY_INSTALLER_NAME") or ( + os.environ.get("DISTRO", "Omarchy") + " installer" + ) + if INSTALLER_TITLE_PATTERN.fullmatch(title) is None: + raise AsahiAdapterError("installer name is invalid for the Recovery setup") + script = ( + STEP2_SCRIPT.replace("##TITLE##", title) + .replace("##VGID##", installer.osi.vgid) + .replace("##PREBOOT##", installer.osi.preboot_vgid) + .replace("##OWNER##", owner) + ) + with open(installer.step2_sh, "w") as fd: + fd.write(script) + os.chmod(installer.step2_sh, 0o755) + + +def stub_installer(sysinfo, dutil, osinfo): + """A StubInstaller whose Recovery setup is Omarchy's own step2.sh.""" + installer = stub.StubInstaller(sysinfo, dutil, osinfo) + install_files = installer.install_files + + def install_files_with_omarchys_step2(cur_os): + result = install_files(cur_os) + _write_step2(installer) + return result + + installer.install_files = install_files_with_omarchys_step2 + return installer + + class AsahiInPlaceRepairAdapter: """Non-partitioning adapter for one manifest-bound installed system.""" @@ -238,7 +498,7 @@ def _authorize_boot_policy(self, plan, stub_identifier): if len(matches) != 1: raise AsahiAdapterError("repair stub identity changed") target = matches[0] - existing = stub.StubInstaller( + existing = stub_installer( self.installer.sysinfo, self.installer.dutil, self.installer.osinfo, @@ -351,7 +611,7 @@ def preflight(self, plan): ipsw = self.installer.choose_ipsw( self.template.get("supported_fw"), ) - self.installer.ins = stub.StubInstaller( + self.installer.ins = stub_installer( self.installer.sysinfo, self.installer.dutil, self.installer.osinfo, diff --git a/Engine/overlay/src/omarchy_runtime.py b/Engine/overlay/src/omarchy_runtime.py index 28cb915..abfd0f1 100644 --- a/Engine/overlay/src/omarchy_runtime.py +++ b/Engine/overlay/src/omarchy_runtime.py @@ -24,6 +24,7 @@ "OMARCHY_MACHINE_OWNER", "DISTRO", "DISTRO_DOCS", + "OMARCHY_INSTALLER_NAME", } diff --git a/Engine/overlay/tests/test_omarchy_asahi.py b/Engine/overlay/tests/test_omarchy_asahi.py index d1c196f..81c5bd4 100644 --- a/Engine/overlay/tests/test_omarchy_asahi.py +++ b/Engine/overlay/tests/test_omarchy_asahi.py @@ -8,6 +8,10 @@ from types import SimpleNamespace import sys import tempfile +import re +import shutil +import time +import subprocess import unittest from unittest.mock import patch import zipfile @@ -46,10 +50,15 @@ def install(self, installer): class FakeStubInstaller: def __init__(self, sysinfo, dutil, osinfo): self.calls = [] + self.recovery = tempfile.TemporaryDirectory() self.osi = SimpleNamespace( vgid="vgid-1", sys_volume="System", + recovery=self.recovery.name, + preboot_vgid="preboot-1", ) + # Where the real stub writes the Recovery setup that Omarchy replaces. + self.step2_sh = os.path.join(self.recovery.name, "step2.sh") self.icon_path = dutil.stub_icon_path def load_ipsw(self, ipsw): @@ -89,9 +98,11 @@ def prepare_for_step2(self): ) from omarchy_asahi import ( # noqa: E402 + STEP2_SCRIPT, AsahiAdapterError, AsahiInPlaceRepairAdapter, AsahiStage1Adapter, + stub_installer, ) @@ -811,5 +822,190 @@ def check_cur_os(self): self.check_cur_os_calls += 1 +# A kmutil that prints each prompt before reading its answer. +PROMPTING_KMUTIL = """printf 'Are you sure you want to do this? (enter y or n) '; read a +echo 'updating local machine policy...'; printf 'Username: '; read u; printf 'Password: '; read p +printf '%s %s %s' "$a" "$u" "$p" >"$PIPED" +[ "$a $u $p" = "y scott secret" ]""" + + +class Step2ScriptTests(unittest.TestCase): + """The Recovery setup: the installer's own name and one password prompt.""" + + title = "Probe Installer" + + def make(self, owner): + root = tempfile.TemporaryDirectory() + self.addCleanup(root.cleanup) + step2 = Path(root.name) / "step2.sh" + step2.write_text("asahi step2") + + class Stub: + def __init__(self, *args): + self.osi = SimpleNamespace(vgid="VG-1", preboot_vgid="PB-1", recovery=root.name) + self.step2_sh = str(step2) + + def load_identity(self): + pass + + def collect_firmware(self, pkg): + pass + + def install_files(self, cur_os): + pass + + with patch("omarchy_asahi.stub.StubInstaller", Stub), patch.dict( + os.environ, {"OMARCHY_MACHINE_OWNER": owner, "OMARCHY_INSTALLER_NAME": self.title} + ): + installer = stub_installer("sysinfo", "dutil", "osinfo") + installer.install_files("cur-os") + return step2 + + def test_the_setup_is_branded_and_asks_for_the_password_once(self): + step2 = self.make("scott") + text = step2.read_text() + self.assertTrue(os.access(step2, os.X_OK)) + self.assertIn("${BOLD}Probe Installer${RST}", text) + self.assertNotIn("MX Mac", text) + self.assertEqual(re.findall(r"##[A-Z]+##", text), []) + self.assertIn('VGID="VG-1"', text) + self.assertIn('PREBOOT="PB-1"', text) + self.assertIn('OWNER="scott"', text) + # One prompt on each path: the not-paired path shuts down after its own. + self.assertEqual(text.count("read -r PASSWORD"), 2) + self.assertIn('bputil -nc -v "$VGID" -u "$OWNER" -p "$PASSWORD"', text) + # kmutil runs on a hidden terminal and gets each answer at its prompt. + self.assertIn('script -q -t 0 "$kmutil_log" kmutil configure-boot -c boot.bin --raw --entry-point 2048', text) + self.assertIn("kmutil_wait_for 'Password:' && printf '%s\\n' \"$PASSWORD\"", text) + self.assertEqual(text.count("Are you sure you want to do this? (y or n)"), 1) + # A healthy run takes under a minute; a stalled one is stopped then. + self.assertIn('spin_until "$kmutil_status" 60', text) + # The recoveryOS checks stay. + self.assertIn("': Paired'", text) + self.assertIn("'one true recoveryOS'", text) + result = subprocess.run(["bash", "-n", str(step2)]) + self.assertEqual(result.returncode, 0) + + def run_step2(self, kmutil_body, answer="y", bputil_fails_once=False, paired=True, stdin=None): + """Run step2.sh in place against fake recoveryOS tools.""" + step2 = self.make("scott") + root = Path(step2).parent + resources = root / "Omarchy" / "Finish Installation.app" / "Contents" / "Resources" + resources.mkdir(parents=True) + script = resources / "step2.sh" + script.write_text(step2.read_text()) + script.chmod(0o755) + bin_dir = root / "bin" + bin_dir.mkdir() + fakes = { + "bputil": f"[ \"$1\" = -d ] && echo 'OS Pairing Status: {'Paired' if paired else 'Not Paired'}'" + " && echo 'OS Type: one true recoveryOS' && exit 0\n" + "echo 'It should only be used to understand how the security works.'\necho 'Use at your own risk!'\n" + + ('[ -e \"$CALLS.bp\" ] && exit 0; touch \"$CALLS.bp\"; exit 1' if bputil_fails_once else "exit 0"), + "stty": "exit 0", + "mount": "exit 0", + "reboot": "exit 0", + # script -q -t 0 log command...: the command's output goes to the log. + "script": 'log=$4; shift 4; exec "$@" >"$log" 2>&1', + # Every sleep lasts 50 ms, so the one-minute deadline is twelve seconds. + "sleep": f"exec {shutil.which('sleep')} 0.05", + "bless": 'echo "$*" >"$CALLS.bless"; cat >>"$CALLS.bless"', + "shutdown": "exit 0", + "kmutil": 'n=$(($(cat "$CALLS" 2>/dev/null || echo 0) + 1)); echo "$n" >"$CALLS"\n' + kmutil_body, + } + for name, body in fakes.items(): + (bin_dir / name).write_text("#!/bin/sh\n" + body + "\n") + (bin_dir / name).chmod(0o755) + calls = root / "kmutil-calls" + env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}", CALLS=str(calls), + PIPED=str(root / "piped")) + result = subprocess.run(["/bin/sh", str(script)], input=stdin or ("wrong\n" if bputil_fails_once else "") + f"secret\n{answer}\n\n", env=env, + capture_output=True, text=True, timeout=60) + if answer != "y" or not paired: + return result, calls, root + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout, int(calls.read_text()), root + + def test_the_owner_can_decline(self): + result, calls, _ = self.run_step2("exit 0", answer="n") + self.assertEqual(result.returncode, 1) + self.assertIn("boot loader was not installed", result.stdout) + self.assertFalse(calls.exists()) + + def test_kmutil_is_answered_out_of_sight(self): + out, calls, root = self.run_step2(PROMPTING_KMUTIL) + self.assertEqual(calls, 1) + self.assertEqual((root / "piped").read_text(), "y scott secret") + self.assertNotIn("Username", out) + self.assertNotIn("type y", out) + self.assertEqual(out.count("Password for scott:"), 1) + # Our own question comes after the one password prompt. + self.assertLess(out.index("Password for scott:"), out.index("Are you sure you want to do this? (y or n)")) + + def test_a_kmutil_that_fails_is_handed_to_the_owner(self): + started = time.monotonic() + out, calls, _ = self.run_step2('[ "$n" -gt 1 ] && exit 0\necho "Username: Password:"; exit 1') + self.assertEqual(calls, 2) + self.assertIn("Type y, then your user name and password", out) + self.assertNotIn("Username:", out) + # A failure is handed over at once, not after the one-minute deadline. + self.assertLess(time.monotonic() - started, 8) + + def test_a_kmutil_that_stalls_is_stopped_and_handed_to_the_owner(self): + out, calls, _ = self.run_step2('[ "$n" -gt 1 ] && exit 0\nexec ' + shutil.which('sleep') + ' 30') + self.assertEqual(calls, 2) + self.assertIn("Type y, then your user name and password", out) + + def test_a_wrong_password_is_named_without_bputils_disclaimer(self): + out, calls, _ = self.run_step2(PROMPTING_KMUTIL, bputil_fails_once=True) + self.assertIn("That password didn't work for scott. Try again.", out) + self.assertNotIn("Use at your own risk", out) + # Each attempt says what it is doing while bputil works. + self.assertEqual(out.count("Updating Omarchy's security settings..."), 2) + self.assertEqual(calls, 1) + + def test_every_line_fits_an_80_column_terminal(self): + text = self.make("scott").read_text() + for line in text.splitlines(): + match = re.match(r'\s*(?:echo|printf)\s+"(.*)"', line) + if not match: + continue + shown = re.sub(r"\$\{(BOLD|RST)\}", "", match.group(1)) + shown = shown.replace("$os_name", "Omarchy").replace("\\n", "") + self.assertLessEqual(len(shown), 76, shown) + + def test_an_unpaired_recovery_blesses_with_the_known_owner(self): + result, calls, root = self.run_step2(PROMPTING_KMUTIL, paired=False, stdin="secret\n\n") + self.assertEqual(result.returncode, 1) + self.assertIn("Password for scott:", result.stdout) + self.assertEqual( + Path(f"{calls}.bless").read_text().split("\n")[:2], + [f"--setBoot --mount {root / 'Omarchy'} --user scott --stdinpass", "secret"], + ) + self.assertIn("choose Omarchy, and log in.", result.stdout) + self.assertFalse(calls.exists()) + + def test_an_unknown_owner_is_asked_for(self): + text = self.make("").read_text() + self.assertIn('OWNER=""', text) + self.assertIn('if [ -z "$OWNER" ]', text) + + def test_a_title_that_could_break_the_script_is_refused(self): + self.title = 'Probe"; reboot; "' + with self.assertRaises(AsahiAdapterError): + self.make("scott") + + def test_an_owner_that_could_break_the_script_is_refused(self): + with self.assertRaises(AsahiAdapterError): + self.make('scott"; rm -rf /; "') + + def test_the_template_has_only_its_four_placeholders(self): + import re as _re + self.assertEqual( + set(_re.findall(r"##[A-Z]+##", STEP2_SCRIPT)), + {"##TITLE##", "##VGID##", "##PREBOOT##", "##OWNER##"}, + ) + + if __name__ == "__main__": unittest.main() diff --git a/Engine/overlay/tests/test_omarchy_runtime.py b/Engine/overlay/tests/test_omarchy_runtime.py index 864df4d..82f8c9e 100644 --- a/Engine/overlay/tests/test_omarchy_runtime.py +++ b/Engine/overlay/tests/test_omarchy_runtime.py @@ -75,6 +75,7 @@ def test_install_requires_owner_and_recovery_branding(self): "OMARCHY_MACHINE_OWNER", "DISTRO", "DISTRO_DOCS", + "OMARCHY_INSTALLER_NAME", ): environment = self._install_environment() del environment[key] @@ -411,7 +412,8 @@ def _install_environment(self): "OMARCHY_ENGINE_BINDING_DIGEST": "sha256:" + "b" * 64, "OMARCHY_ENGINE_PLAN_DIGEST": self.plan.plan_digest, "OMARCHY_MACHINE_OWNER": "mina", - "DISTRO": "Omarchy MX Mac", + "DISTRO": "Omarchy", + "OMARCHY_INSTALLER_NAME": "Probe Installer", "DISTRO_DOCS": "https://omarchy.org/manual/", } diff --git a/Engine/rebuild-python-overlay.py b/Engine/rebuild-python-overlay.py index 9b84b4d..3046760 100644 --- a/Engine/rebuild-python-overlay.py +++ b/Engine/rebuild-python-overlay.py @@ -15,7 +15,7 @@ BASE_SHA256 = '9e9277384b6c9e8b269cc79b1b24df7bfcdcbb898a596a677b74d1d18050aebe' BASE_COMMIT = 'f0469cea0899f3efed8efead604174c7a53c4451' -VERSION = 'v0.9.2-omarchy.27' +VERSION = 'v0.9.2-omarchy.28' _SPEC = importlib.util.spec_from_file_location( 'verify_source_lock', Path(__file__).resolve().parent / 'verify-source-lock.py') diff --git a/Engine/source-lock.json b/Engine/source-lock.json index d527c55..424e210 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -141,7 +141,7 @@ } }, "downstream_overlay": { - "version": "v0.9.2-omarchy.27", + "version": "v0.9.2-omarchy.28", "capability": "candidate_bound_full_os_stage_one_authenticated_recovery", "engine_modes": [ "inspect", @@ -171,7 +171,7 @@ { "path": "overlay/src/omarchy_asahi.py", "destination": "src/omarchy_asahi.py", - "sha256": "74bed0f998c906afc6fe72ef8c9f0d6fd6baac330e206e084d7ef2879840ab16" + "sha256": "830fc9140d3aba975d62e74144330aba8d7396de7af4b958a4df4734fc758dc0" }, { "path": "overlay/src/omarchy_contract.py", @@ -196,7 +196,7 @@ { "path": "overlay/src/omarchy_runtime.py", "destination": "src/omarchy_runtime.py", - "sha256": "8d5296fe17fe1bac6340a8c2433d0968f9d7f32f262e8a927dd507ec1efb823d" + "sha256": "001828ec63e3d5c3c437fd6f719ef717e1f8df95ff3771c13bd341c08a3ef73b" }, { "path": "overlay/src/omarchy_stage1.py", @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "b5c07f8a61273d25cfd1ef3fb76208241ebdb29ff2e2832ee16c2d63d01e9e00" + "sha256": "4ebbed0c46a1e31ff7004f1cb152c7bf8f5735ffe4b146db43763bddb15a18ec" }, { "path": "overlay/tests/test_omarchy_contract.py", @@ -236,7 +236,7 @@ { "path": "overlay/tests/test_omarchy_runtime.py", "destination": "tests/test_omarchy_runtime.py", - "sha256": "02f4a46d6159f7e189b6e2349dfe43184a6e99256a51a066dd95d02f0500ee57" + "sha256": "ae4e9336afa7dab09407467a4f886206c0c72e5ef3db22ff9ae962b83119043b" }, { "path": "overlay/tests/test_omarchy_stage1.py", @@ -270,7 +270,7 @@ }, { "path": "rebuild-python-overlay.py", - "sha256": "e4a6c4f9a3b5e9410d2511c16bf85899e9630713791acf8c59c669bce15e02d0" + "sha256": "3cc29ff5c7bd834b40a50ef2271417efcd33bc7bc747b11c743d5ba2042017c2" } ], "validation_artifact": { diff --git a/Sources/OmarchyAppleInstaller/PinnedAsahiEngineExecutor.swift b/Sources/OmarchyAppleInstaller/PinnedAsahiEngineExecutor.swift index 4804c8d..323b0c8 100644 --- a/Sources/OmarchyAppleInstaller/PinnedAsahiEngineExecutor.swift +++ b/Sources/OmarchyAppleInstaller/PinnedAsahiEngineExecutor.swift @@ -423,7 +423,8 @@ "OMARCHY_ENGINE_BINDING_DIGEST": package.bindingDigest, "OMARCHY_ENGINE_PLAN_DIGEST": package.planDigest, "OMARCHY_MACHINE_OWNER": authorization.username, - "DISTRO": "Omarchy MX Mac", + "DISTRO": "Omarchy", + "OMARCHY_INSTALLER_NAME": InstallerProductIdentity.appName, "DISTRO_DOCS": "https://omarchy.org/manual/", ] if let repairManifestURL = package.repairManifestURL { diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift index a7a8ddc..362fc8d 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/PinnedAsahiEngineExecutorTests.swift @@ -565,7 +565,8 @@ ;; install|retry-recovery-authorization) [ "$OMARCHY_ENGINE_MODE" = "\(expectedInstallMode)" ] || exit 64 - [ "$DISTRO" = "Omarchy MX Mac" ] || exit 68 + [ "$DISTRO" = "Omarchy" ] || exit 68 + [ "$OMARCHY_INSTALLER_NAME" = "\(InstallerProductIdentity.appName)" ] || exit 83 [ "$DISTRO_DOCS" = "https://omarchy.org/manual/" ] || exit 69 [ "$OMARCHY_MACHINE_OWNER" = "mina" ] || exit 67 IFS= read -r owner_password || exit 66 diff --git a/scripts/release-inputs-aurora.template.json b/scripts/release-inputs-aurora.template.json index 2982bec..12896f5 100644 --- a/scripts/release-inputs-aurora.template.json +++ b/scripts/release-inputs-aurora.template.json @@ -1,8 +1,8 @@ { "payload_name": "omarchy-2026.09.13-aarch64-apple-silicon-aurora-os-package.zip", - "engine_name": "installer-v0.9.2-omarchy.27.tar.gz", + "engine_name": "installer-v0.9.2-omarchy.28.tar.gz", "metadata_name": "installer_data.json", - "engine_version": "v0.9.2-omarchy.27", + "engine_version": "v0.9.2-omarchy.28", "evidence_revision": "4.0.3-mac.2.20260913-aurora", "asahi_installer_tag": "v0.9.2", "asahi_installer_revision": "dffbb38ef0c00c0431c609ecd8a00f42deb5b24c", diff --git a/scripts/release-inputs.template.json b/scripts/release-inputs.template.json index afe8cd5..a72dd84 100644 --- a/scripts/release-inputs.template.json +++ b/scripts/release-inputs.template.json @@ -1,8 +1,8 @@ { "payload_name": "omarchy-2026.09.13-aarch64-apple-silicon-asahi-os-package.zip", - "engine_name": "installer-v0.9.2-omarchy.27.tar.gz", + "engine_name": "installer-v0.9.2-omarchy.28.tar.gz", "metadata_name": "installer_data.json", - "engine_version": "v0.9.2-omarchy.27", + "engine_version": "v0.9.2-omarchy.28", "evidence_revision": "4.0.3-mac.1.20260913", "asahi_installer_tag": "v0.9.2", "asahi_installer_revision": "dffbb38ef0c00c0431c609ecd8a00f42deb5b24c", From 31824b74fb01ac6ee733b8a5b0d1848b5cb67ab1 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Fri, 2 Oct 2026 22:34:05 -0400 Subject: [PATCH 2/9] Ask before changing anything, and let Ctrl-C stop the Recovery step From review: bputil lowered the stub's security before the owner was asked, so declining left it lowered. "Are you sure?" now comes first, and "n" changes nothing. bputil and kmutil now run in the foreground, where Ctrl-C reaches them, with the spinner in the background. On any exit a handler stops the spinner and any hidden kmutil, restores echo, clears the password and deletes the /tmp logs. kmutil runs under script through a wrapper that records its own process ID, so the one-minute watchdog stops kmutil itself and the fallback never runs two at once. After three bputil failures, bputil asks the owner itself, as upstream did, instead of looping. Passwords keep leading and trailing spaces (IFS= read). A rejected password replaces the "Updating" line with the reason. On the wrong Recovery, bless --setBoot exits 0 and ignores the credentials it asks for, so step 2 checks the startup disk (bless --getBoot against Omarchy's volume group) instead, asks nothing when Omarchy already is the startup disk, and tries bless without a password before asking for one. OMARCHY_INSTALLER_NAME is optional again, so released apps keep working with this engine; the title then falls back to DISTRO. Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/src/omarchy_asahi.py | 294 ++++++++++++------- Engine/overlay/src/omarchy_runtime.py | 20 +- Engine/overlay/tests/test_omarchy_asahi.py | 268 +++++++++++++---- Engine/overlay/tests/test_omarchy_runtime.py | 12 +- Engine/source-lock.json | 8 +- 5 files changed, 418 insertions(+), 184 deletions(-) diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index 0b3320f..1974bbc 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -58,6 +58,94 @@ class AsahiAdapterError(RuntimeError): BOLD="$(printf '\\033[1m')" RST="$(printf '\\033[m')" +LOGS="/tmp/bp.txt /tmp/bless.log /tmp/bputil.log /tmp/bputil.status +/tmp/kmutil.log /tmp/kmutil.status /tmp/kmutil.pid /tmp/kmutil.done" +spinner_pid="" + +# Stop the process whose ID is in file $1, and wait until it is gone. +stop_pid_in() { + [ -s "$1" ] || return 0 + pid="$(cat "$1")" + kill "$pid" 2>/dev/null || return 0 + waited=0 + while kill -0 "$pid" 2>/dev/null; do + if [ "$waited" -eq 20 ]; then + kill -9 "$pid" 2>/dev/null || true + fi + sleep 0.1 + waited=$((waited + 1)) + done +} + +# On any exit: stop what this script started, restore the terminal, and +# forget the password and the logs. +cleanup() { + if [ -n "$spinner_pid" ]; then + kill "$spinner_pid" 2>/dev/null || true + spinner_pid="" + fi + stop_pid_in /tmp/kmutil.pid + stty echo 2>/dev/null || true + PASSWORD="" + rm -f $LOGS +} +trap cleanup EXIT +# bputil and kmutil run in the foreground, so Ctrl-C reaches them too. +trap 'echo; echo "Stopped. Run this again to finish setting up $os_name."; exit 130' INT TERM HUP + +# Spin after the current line until file $1 exists. After $2 seconds, stop +# the process whose ID is in file $3, if one is given. Runs in the background. +spin_until() { + spun=0 + printf ' ' + while [ ! -e "$1" ]; do + if [ "$spun" -eq $(($2 * 4)) ] && [ -n "$3" ]; then + stop_pid_in "$3" + fi + case $((spun % 4)) in + 0) c='|' ;; + 1) c='/' ;; + 2) c='-' ;; + *) c='\\' ;; + esac + printf '\\b%s' "$c" + sleep 0.25 + spun=$((spun + 1)) + done + printf '\\b ' +} + +# Run the rest of the line with a spinner after the current line, which the +# caller ends. Its exit status lands in file $1; after $2 seconds the process +# in file $3 is stopped. +run_with_spinner() { + status_file=$1 + deadline=$2 + pid_file=$3 + shift 3 + rm -f "$status_file" + spin_until "$status_file" "$deadline" "$pid_file" & + spinner_pid=$! + status=0 + "$@" || status=$? + echo "$status" >"$status_file" + wait "$spinner_pid" 2>/dev/null || true + spinner_pid="" + return "$status" +} + +bputil_now() { + bputil -nc -v "$VGID" -u "$OWNER" -p "$PASSWORD" >/tmp/bputil.log 2>&1 +} + +read_password() { + printf "Password for %s: " "$OWNER" + stty -echo + IFS= read -r PASSWORD + stty echo + echo +} + printf '\\033[2J\\033[H' echo "${BOLD}##TITLE##${RST}" echo @@ -69,35 +157,51 @@ class AsahiAdapterError(RuntimeError): read -r OWNER fi +# Whether the startup disk is this Omarchy: bless names a volume of its group. +omarchy_is_startup() { + boot="$(bless --getBoot 2>/dev/null)" || return 1 + [ -n "$boot" ] || return 1 + diskutil info "$boot" 2>/dev/null | grep -q "$VGID" && return 0 + [ "$boot" = "$(diskutil info "$system_dir" 2>/dev/null | sed -n 's/^ *Device Node: *//p')" ] +} + if ! grep -q ': Paired' /tmp/bp.txt; then - echo "This step needs $os_name's own Recovery. Making $os_name the startup" - echo "disk so that the next start opens it." - echo - # bless takes the known owner and the password on stdin, so it asks for - # neither itself. After three failures, let bless ask on its own. - tries=0 - while :; do - printf "Password for %s: " "$OWNER" - stty -echo - read -r PASSWORD - stty echo - echo - if printf '%s\\n' "$PASSWORD" | bless --setBoot --mount "$system_dir" --user "$OWNER" --stdinpass >/tmp/bless.log 2>&1; then - break - fi - tries=$((tries + 1)) - if [ "$tries" -ge 3 ]; then - echo "macOS asks itself now. Type your user name and password." - while ! bless --setBoot --mount "$system_dir"; do - echo "That didn't work. Press Enter to try again." - read - done - break - fi - echo "That password didn't work for $OWNER. Try again." + if omarchy_is_startup; then + echo "This step needs $os_name's own Recovery. $os_name is already the" + echo "startup disk, so the next start opens it." + else + echo "This step needs $os_name's own Recovery. Making $os_name the startup" + echo "disk so that the next start opens it." + # After the Recovery login, recoveryOS's bless has set the startup disk + # without checking the password (macOS 26.6.2), so try that first. + printf '\\n' | bless --setBoot --mount "$system_dir" --user "$OWNER" --stdinpass >/tmp/bless.log 2>&1 || true + fi + if ! omarchy_is_startup; then echo - done - PASSWORD="" + # bless takes the known owner and the password on stdin. It exits 0 + # even when it rejects the password, so check the startup disk instead. + # After three failures, let bless ask on its own. + tries=0 + while :; do + read_password + printf '%s\\n' "$PASSWORD" | bless --setBoot --mount "$system_dir" --user "$OWNER" --stdinpass >/tmp/bless.log 2>&1 || true + if omarchy_is_startup; then + break + fi + tries=$((tries + 1)) + if [ "$tries" -ge 3 ]; then + echo "macOS asks itself now. Type your user name and password." + until bless --setBoot --mount "$system_dir" && omarchy_is_startup; do + echo "That didn't work. Press Enter to try again." + read + done + break + fi + echo "That password didn't work for $OWNER. Try again." + echo + done + PASSWORD="" + fi echo echo "Press Enter to shut down. Then hold the power button until you see" echo "'Loading startup options', choose $os_name, and log in." @@ -119,51 +223,39 @@ class AsahiAdapterError(RuntimeError): echo "This lets $os_name start Linux by lowering the security level of" echo "$os_name only. ${BOLD}macOS keeps Full Security.${RST}" echo - -# Spin after the current line until the file $1 exists, at most $2 seconds. -spin_until() { - spun=0 - printf ' ' - while [ ! -e "$1" ] && [ "$spun" -lt $(($2 * 4)) ]; do - case $((spun % 4)) in - 0) c='|' ;; - 1) c='/' ;; - 2) c='-' ;; - *) c='\\' ;; - esac - printf '\\b%s' "$c" - sleep 0.25 - spun=$((spun + 1)) - done - printf '\\b \\n' -} +# Ask before anything changes: bputil lowers the security level at once. +printf "Are you sure you want to do this? (y or n) " +read -r answer +case "$answer" in + y|Y|yes|Yes|YES) ;; + *) + echo "Nothing was changed. Run this again when you're ready." + exit 1 + ;; +esac +echo tries=0 while :; do - printf "Password for %s: " "$OWNER" - stty -echo - read -r PASSWORD - stty echo - echo + read_password printf "Updating %s's security settings..." "$os_name" - rm -f /tmp/bputil.status - { - # set -e would end this block before it records a failure. - status=0 - bputil -nc -v "$VGID" -u "$OWNER" -p "$PASSWORD" >/tmp/bputil.log 2>&1 || status=$? - echo "$status" >/tmp/bputil.status.new - mv /tmp/bputil.status.new /tmp/bputil.status - } & - spin_until /tmp/bputil.status 600 - if [ "$(cat /tmp/bputil.status 2>/dev/null)" = 0 ]; then + if run_with_spinner /tmp/bputil.status 0 "" bputil_now; then + echo break fi - # bputil's log opens with its own disclaimer, so don't show it here. + # Nothing was updated: replace that line. bputil's log opens with its own + # disclaimer, so don't show it here. + printf '\\r\\033[K' tries=$((tries + 1)) - echo "That password didn't work for $OWNER. Try again." if [ "$tries" -ge 3 ]; then - echo "If the password is right, /tmp/bputil.log says what went wrong." + echo "macOS asks itself now. Type your user name and password." + while ! bputil -nc -v "$VGID"; do + echo "That didn't work. Press Enter to try again." + read + done + break fi + echo "That password didn't work for $OWNER. Try again." echo done @@ -175,65 +267,55 @@ class AsahiAdapterError(RuntimeError): cp -R "$preboot/$VGID/var" "/System/Volumes/iSCPreboot/$VGID/" fi -echo -printf "Are you sure you want to do this? (y or n) " -read -r answer -case "$answer" in - y|Y|yes|Yes|YES) ;; - *) - echo "Omarchy's boot loader was not installed. Run this again when you're ready." - exit 1 - ;; -esac -echo printf "Installing Omarchy's boot loader..." # kmutil asks "are you sure" on stdin, then reads a user name and password # from its terminal, discarding anything typed ahead. So run it on a hidden # terminal (script) and type each answer once its prompt appears. The -# password is typed with echo off, so the log never holds it. If kmutil -# fails or asks again, stop it after a minute and let the owner answer it. -# Nothing waits on the background jobs: kmutil's status lands in a file. -# (A background job's input is /dev/null unless redirected, hence <&0.) +# password is typed with echo off, so the log never holds it. A small +# wrapper records kmutil's own process ID, so that if kmutil fails or is +# still running after a minute, it is stopped and the owner answers it. kmutil_log=/tmp/kmutil.log kmutil_status=/tmp/kmutil.status kmutil_wait_for() { tries=0 while ! grep -q "$1" "$kmutil_log" 2>/dev/null; do - [ ! -e "$kmutil_status" ] && [ "$tries" -lt 600 ] || return 1 + [ ! -e /tmp/kmutil.done ] && [ "$tries" -lt 600 ] || return 1 sleep 0.1 tries=$((tries + 1)) done } +kmutil_answers() { + kmutil_wait_for 'enter y or n' && printf 'y\\n' && + kmutil_wait_for 'Username:' && printf '%s\\n' "$OWNER" && + kmutil_wait_for 'Password:' && printf '%s\\n' "$PASSWORD" + # Keep kmutil's input open until it exits. + while [ ! -e /tmp/kmutil.done ]; do + sleep 0.1 + done +} +hidden_kmutil() { + rm -f /tmp/kmutil.done + kmutil_answers 2>/dev/null | { + status=0 + script -q -t 0 "$kmutil_log" \\ + /bin/sh -c 'echo $$ >/tmp/kmutil.pid; exec kmutil configure-boot -c boot.bin --raw --entry-point 2048 --lowest-virtual-address 0 -v "$1"' \\ + sh "$system_dir" >/dev/null 2>&1 || status=$? + # kmutil has exited: never signal its process ID again. + rm -f /tmp/kmutil.pid + touch /tmp/kmutil.done + exit "$status" + } +} kmutil_ok=no if command -v script >/dev/null 2>&1; then - rm -f "$kmutil_log" "$kmutil_status" /tmp/kmutil.pid - { - kmutil_wait_for 'enter y or n' && printf 'y\\n' && - kmutil_wait_for 'Username:' && printf '%s\\n' "$OWNER" && - kmutil_wait_for 'Password:' && printf '%s\\n' "$PASSWORD" - # Keep kmutil's input open until it exits. - tries=0 - while [ ! -e "$kmutil_status" ] && [ "$tries" -lt 700 ]; do - sleep 0.1 - tries=$((tries + 1)) - done - } 2>/dev/null | { - script -q -t 0 "$kmutil_log" kmutil configure-boot -c boot.bin --raw --entry-point 2048 --lowest-virtual-address 0 -v "$system_dir" <&0 >/dev/null 2>&1 & - echo $! >/tmp/kmutil.pid - status=0 - wait $! || status=$? - echo "$status" >"$kmutil_status.new" - mv "$kmutil_status.new" "$kmutil_status" - } & - spin_until "$kmutil_status" 60 - if [ -e "$kmutil_status" ]; then - [ "$(cat "$kmutil_status")" = 0 ] && kmutil_ok=yes - else - kill "$(cat /tmp/kmutil.pid)" 2>/dev/null || true - sleep 1 - kill -9 "$(cat /tmp/kmutil.pid)" 2>/dev/null || true + rm -f "$kmutil_log" /tmp/kmutil.pid + if run_with_spinner "$kmutil_status" 60 /tmp/kmutil.pid hidden_kmutil; then + kmutil_ok=yes fi + echo + stop_pid_in /tmp/kmutil.pid fi +PASSWORD="" if [ "$kmutil_ok" != yes ]; then echo echo "macOS asks once more. Type y, then your user name and password." @@ -242,7 +324,6 @@ class AsahiAdapterError(RuntimeError): read done fi -PASSWORD="" mount -u -w "$system_dir" if [ -e "$system_dir/.IAPhysicalMedia" ]; then @@ -251,6 +332,7 @@ class AsahiAdapterError(RuntimeError): if [ -e "$system_dir/System/Library/CoreServices/SystemVersion-disabled.plist" ]; then mv -f "$system_dir/System/Library/CoreServices/SystemVersion"{-disabled,}".plist" fi +cleanup echo echo "${BOLD}Done.${RST} Press Enter to restart into $os_name." diff --git a/Engine/overlay/src/omarchy_runtime.py b/Engine/overlay/src/omarchy_runtime.py index abfd0f1..d449700 100644 --- a/Engine/overlay/src/omarchy_runtime.py +++ b/Engine/overlay/src/omarchy_runtime.py @@ -27,6 +27,14 @@ "OMARCHY_INSTALLER_NAME", } +# Released apps predate OMARCHY_INSTALLER_NAME; without it the Recovery +# setup is titled from DISTRO. +OPTIONAL_INSTALL_KEYS = { + "OMARCHY_ENGINE_MODE", + "OMARCHY_ENGINE_REPAIR_MANIFEST", + "OMARCHY_INSTALLER_NAME", +} + class EngineRuntimeError(omarchy_contract.ContractError): pass @@ -71,16 +79,8 @@ def from_environment(cls, environment=None): "OMARCHY_ENGINE_REQUEST", "OMARCHY_ENGINE_IDENTITY", }, - "install": ENVIRONMENT_KEYS - - { - "OMARCHY_ENGINE_MODE", - "OMARCHY_ENGINE_REPAIR_MANIFEST", - }, - "retry-recovery-authorization": ENVIRONMENT_KEYS - - { - "OMARCHY_ENGINE_MODE", - "OMARCHY_ENGINE_REPAIR_MANIFEST", - }, + "install": ENVIRONMENT_KEYS - OPTIONAL_INSTALL_KEYS, + "retry-recovery-authorization": ENVIRONMENT_KEYS - OPTIONAL_INSTALL_KEYS, }[mode] missing = sorted(required - set(values)) if missing: diff --git a/Engine/overlay/tests/test_omarchy_asahi.py b/Engine/overlay/tests/test_omarchy_asahi.py index 81c5bd4..f637698 100644 --- a/Engine/overlay/tests/test_omarchy_asahi.py +++ b/Engine/overlay/tests/test_omarchy_asahi.py @@ -822,11 +822,18 @@ def check_cur_os(self): self.check_cur_os_calls += 1 -# A kmutil that prints each prompt before reading its answer. -PROMPTING_KMUTIL = """printf 'Are you sure you want to do this? (enter y or n) '; read a -echo 'updating local machine policy...'; printf 'Username: '; read u; printf 'Password: '; read p -printf '%s %s %s' "$a" "$u" "$p" >"$PIPED" -[ "$a $u $p" = "y scott secret" ]""" +# A kmutil that prints each prompt before reading its answer, and echoes what +# a terminal would show: the confirmation and the user name, but never the +# password, which a real terminal reads with echo off. +PROMPTING_KMUTIL = """printf 'Are you sure you want to do this? (enter y or n) '; IFS= read -r a; echo "$a" +echo 'updating local machine policy...'; printf 'Username: '; IFS= read -r u; echo "$u" +printf 'Password: '; IFS= read -r p; echo +printf '%s|%s|%s' "$a" "$u" "$p" >"$PIPED" +[ "$a|$u|$p" = "y|scott|$EXPECTED_PASSWORD" ]""" + +# Files step2.sh may leave in /tmp while it runs; none may outlive it. +STEP2_LOGS = ("bp.txt", "bless.log", "bputil.log", "bputil.status", "kmutil.log", + "kmutil.status", "kmutil.pid", "kmutil.done") class Step2ScriptTests(unittest.TestCase): @@ -871,23 +878,24 @@ def test_the_setup_is_branded_and_asks_for_the_password_once(self): self.assertIn('VGID="VG-1"', text) self.assertIn('PREBOOT="PB-1"', text) self.assertIn('OWNER="scott"', text) - # One prompt on each path: the not-paired path shuts down after its own. - self.assertEqual(text.count("read -r PASSWORD"), 2) + # One prompt, kept whole: leading and trailing spaces are part of it. + self.assertEqual(text.count("read -r PASSWORD"), 1) + self.assertIn("IFS= read -r PASSWORD", text) self.assertIn('bputil -nc -v "$VGID" -u "$OWNER" -p "$PASSWORD"', text) - # kmutil runs on a hidden terminal and gets each answer at its prompt. - self.assertIn('script -q -t 0 "$kmutil_log" kmutil configure-boot -c boot.bin --raw --entry-point 2048', text) - self.assertIn("kmutil_wait_for 'Password:' && printf '%s\\n' \"$PASSWORD\"", text) + # kmutil runs on a hidden terminal, recording its own process ID. + self.assertIn('script -q -t 0 "$kmutil_log"', text) + self.assertIn("echo $$ >/tmp/kmutil.pid; exec kmutil configure-boot -c boot.bin --raw --entry-point 2048", text) + self.assertIn('run_with_spinner "$kmutil_status" 60 /tmp/kmutil.pid hidden_kmutil', text) self.assertEqual(text.count("Are you sure you want to do this? (y or n)"), 1) - # A healthy run takes under a minute; a stalled one is stopped then. - self.assertIn('spin_until "$kmutil_status" 60', text) # The recoveryOS checks stay. self.assertIn("': Paired'", text) self.assertIn("'one true recoveryOS'", text) result = subprocess.run(["bash", "-n", str(step2)]) self.assertEqual(result.returncode, 0) - def run_step2(self, kmutil_body, answer="y", bputil_fails_once=False, paired=True, stdin=None): - """Run step2.sh in place against fake recoveryOS tools.""" + def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password="secret", + startup="macos", bless_checks=True): + """step2.sh in place, with fake recoveryOS tools first on PATH.""" step2 = self.make("scott") root = Path(step2).parent resources = root / "Omarchy" / "Finish Installation.app" / "Contents" / "Resources" @@ -897,72 +905,217 @@ def run_step2(self, kmutil_body, answer="y", bputil_fails_once=False, paired=Tru script.chmod(0o755) bin_dir = root / "bin" bin_dir.mkdir() + sleep = shutil.which("sleep") fakes = { - "bputil": f"[ \"$1\" = -d ] && echo 'OS Pairing Status: {'Paired' if paired else 'Not Paired'}'" - " && echo 'OS Type: one true recoveryOS' && exit 0\n" - "echo 'It should only be used to understand how the security works.'\necho 'Use at your own risk!'\n" - + ('[ -e \"$CALLS.bp\" ] && exit 0; touch \"$CALLS.bp\"; exit 1' if bputil_fails_once else "exit 0"), + "bputil": "\n".join(( + f"[ \"$1\" = -d ] && echo 'OS Pairing Status: {'Paired' if paired else 'Not Paired'}'" + " && echo 'OS Type: one true recoveryOS' && exit 0", + 'echo "$*" >>"$CALLS.bputil"', + # Without -u, bputil asks the owner itself: the fallback. + '[ "$4" = -u ] || exit 0', + "echo 'It should only be used to understand how the security works.'", + "echo 'Use at your own risk!'", + '[ "$7" = "$EXPECTED_PASSWORD" ] || exit 1', + f'[ "$BPUTIL_MODE" = hang ] && echo $$ >"$CALLS.bputil-pid" && exec {sleep} 30', + '[ "$BPUTIL_MODE" = fail ] && exit 1', + "exit 0", + )), "stty": "exit 0", "mount": "exit 0", "reboot": "exit 0", - # script -q -t 0 log command...: the command's output goes to the log. - "script": 'log=$4; shift 4; exec "$@" >"$log" 2>&1', - # Every sleep lasts 50 ms, so the one-minute deadline is twelve seconds. - "sleep": f"exec {shutil.which('sleep')} 0.05", - "bless": 'echo "$*" >"$CALLS.bless"; cat >>"$CALLS.bless"', "shutdown": "exit 0", + # script -q -t 0 log command...: the command's output goes to the + # log, which is kept for the test because step2.sh deletes it. + "script": 'log=$4; shift 4; "$@" >"$log" 2>&1; s=$?; cp "$log" "$CALLS.kmutil-log"; exit $s', + # Every sleep lasts 50 ms, so the one-minute deadline is twelve seconds. + "sleep": f"exec {sleep} 0.05", + # Like macOS's bless, it exits 0 whatever the password, and only + # a right one changes the startup disk. + "bless": "\n".join(( + '[ "$1" = --getBoot ] && { cat "$CALLS.boot"; exit 0; }', + 'echo "$*" >>"$CALLS.bless"', + 'case "$*" in', + '*--stdinpass) IFS= read -r pw; echo "$pw" >>"$CALLS.bless"', + ' { [ "$BLESS_CHECKS" = no ] || [ "$pw" = "$EXPECTED_PASSWORD" ]; } &&' + ' echo /dev/omarchy >"$CALLS.boot" ;;', + # Without --stdinpass, bless asks the owner itself. + '*) echo /dev/omarchy >"$CALLS.boot" ;;', + 'esac', + "exit 0", + )), + "diskutil": "\n".join(( + '[ "$1" = info ] || exit 0', + 'case "$2" in', + "/dev/omarchy|*/Omarchy) printf ' Device Node: /dev/omarchy\\n APFS Volume Group: VG-1\\n' ;;", + "*) printf ' Device Node: /dev/disk0s2\\n APFS Volume Group: MACOS-VG\\n' ;;", + 'esac', + )), "kmutil": 'n=$(($(cat "$CALLS" 2>/dev/null || echo 0) + 1)); echo "$n" >"$CALLS"\n' + kmutil_body, } for name, body in fakes.items(): (bin_dir / name).write_text("#!/bin/sh\n" + body + "\n") (bin_dir / name).chmod(0o755) calls = root / "kmutil-calls" + Path(f"{calls}.boot").write_text("/dev/omarchy\n" if startup == "omarchy" else "/dev/disk0s2\n") env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}", CALLS=str(calls), - PIPED=str(root / "piped")) - result = subprocess.run(["/bin/sh", str(script)], input=stdin or ("wrong\n" if bputil_fails_once else "") + f"secret\n{answer}\n\n", env=env, + PIPED=str(root / "piped"), EXPECTED_PASSWORD=password, BPUTIL_MODE=bputil_mode, + BLESS_CHECKS="yes" if bless_checks else "no") + return script, env, calls + + def run_step2(self, kmutil_body, stdin="y\nsecret\n\n", **options): + script, env, calls = self.step2_with_fakes(kmutil_body, **options) + result = subprocess.run(["/bin/sh", str(script)], input=stdin, env=env, capture_output=True, text=True, timeout=60) - if answer != "y" or not paired: - return result, calls, root - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - return result.stdout, int(calls.read_text()), root + kmutil_calls = int(calls.read_text()) if calls.exists() else 0 + return result, kmutil_calls, calls - def test_the_owner_can_decline(self): - result, calls, _ = self.run_step2("exit 0", answer="n") - self.assertEqual(result.returncode, 1) - self.assertIn("boot loader was not installed", result.stdout) - self.assertFalse(calls.exists()) + def assert_nothing_left_in_tmp(self): + for name in STEP2_LOGS: + self.assertFalse(os.path.exists(f"/tmp/{name}"), name) def test_kmutil_is_answered_out_of_sight(self): - out, calls, root = self.run_step2(PROMPTING_KMUTIL) - self.assertEqual(calls, 1) - self.assertEqual((root / "piped").read_text(), "y scott secret") + result, kmutil_calls, calls = self.run_step2(PROMPTING_KMUTIL) + out = result.stdout + self.assertEqual(result.returncode, 0, out + result.stderr) + self.assertEqual(kmutil_calls, 1) + self.assertEqual(Path(calls.parent, "piped").read_text(), "y|scott|secret") self.assertNotIn("Username", out) self.assertNotIn("type y", out) self.assertEqual(out.count("Password for scott:"), 1) - # Our own question comes after the one password prompt. - self.assertLess(out.index("Password for scott:"), out.index("Are you sure you want to do this? (y or n)")) + # Nothing changes before the owner agrees. + self.assertLess(out.index("Are you sure you want to do this? (y or n)"), out.index("Password for scott:")) + # The terminal shows what is typed, so the password goes only after + # kmutil's own Password: prompt has turned echo off. + log = Path(f"{calls}.kmutil-log").read_text() + self.assertIn("scott", log) + self.assertNotIn("secret", log) + self.assert_nothing_left_in_tmp() + + def test_a_password_with_spaces_reaches_every_tool_intact(self): + password = " two words " + result, kmutil_calls, calls = self.run_step2( + PROMPTING_KMUTIL, stdin=f"y\n{password}\n\n", password=password) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(kmutil_calls, 1) + self.assertEqual(Path(calls.parent, "piped").read_text(), f"y|scott|{password}") + + def test_the_owner_can_decline_before_anything_changes(self): + result, kmutil_calls, calls = self.run_step2("exit 0", stdin="n\n") + self.assertEqual(result.returncode, 1) + self.assertIn("Nothing was changed", result.stdout) + self.assertNotIn("Password for", result.stdout) + self.assertFalse(Path(f"{calls}.bputil").exists()) + self.assertEqual(kmutil_calls, 0) + self.assert_nothing_left_in_tmp() + + def test_a_wrong_password_is_named_without_bputils_disclaimer(self): + result, kmutil_calls, _ = self.run_step2(PROMPTING_KMUTIL, stdin="y\nwrong\nsecret\n\n") + out = result.stdout + self.assertEqual(result.returncode, 0, out + result.stderr) + self.assertIn("That password didn't work for scott. Try again.", out) + self.assertNotIn("Use at your own risk", out) + # Each attempt says what it is doing while bputil works, and a + # rejected one replaces that line with the reason. + self.assertEqual(out.count("Updating Omarchy's security settings..."), 2) + # (Text-mode capture turns the carriage return into a newline.) + self.assertIn("\x1b[KThat password didn't work for scott.", out) + self.assertEqual(kmutil_calls, 1) + + def test_bputil_lets_macos_ask_after_three_failures(self): + result, _, calls = self.run_step2("exit 0", stdin="y\nw1\nw2\nw3\n\n", bputil_mode="fail") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("macOS asks itself now", result.stdout) + attempts = Path(f"{calls}.bputil").read_text().splitlines() + self.assertEqual(len(attempts), 4) + self.assertEqual(attempts[-1], "-nc -v VG-1") def test_a_kmutil_that_fails_is_handed_to_the_owner(self): started = time.monotonic() - out, calls, _ = self.run_step2('[ "$n" -gt 1 ] && exit 0\necho "Username: Password:"; exit 1') - self.assertEqual(calls, 2) + result, kmutil_calls, _ = self.run_step2('[ "$n" -gt 1 ] && exit 0\necho "Username: Password:"; exit 1') + out = result.stdout + self.assertEqual(result.returncode, 0, out + result.stderr) + self.assertEqual(kmutil_calls, 2) self.assertIn("Type y, then your user name and password", out) self.assertNotIn("Username:", out) # A failure is handed over at once, not after the one-minute deadline. self.assertLess(time.monotonic() - started, 8) - def test_a_kmutil_that_stalls_is_stopped_and_handed_to_the_owner(self): - out, calls, _ = self.run_step2('[ "$n" -gt 1 ] && exit 0\nexec ' + shutil.which('sleep') + ' 30') - self.assertEqual(calls, 2) - self.assertIn("Type y, then your user name and password", out) + def test_a_kmutil_that_stalls_is_stopped_before_the_owner_answers(self): + result, kmutil_calls, calls = self.run_step2( + '[ "$n" -gt 1 ] && exit 0\necho $$ >"$CALLS.stalled"\nexec ' + shutil.which("sleep") + " 30") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(kmutil_calls, 2) + self.assertIn("Type y, then your user name and password", result.stdout) + # The stalled kmutil itself, not only script, is gone before the + # second one starts. + with self.assertRaises(ProcessLookupError): + os.kill(int(Path(f"{calls}.stalled").read_text()), 0) + + def test_ctrl_c_stops_everything_it_started(self): + import signal + script, env, calls = self.step2_with_fakes("exit 0", bputil_mode="hang") + process = subprocess.Popen(["/bin/sh", str(script)], env=env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + start_new_session=True) + process.stdin.write("y\nsecret\n") + process.stdin.flush() + hanging = Path(f"{calls}.bputil-pid") + deadline = time.monotonic() + 20 + while not hanging.exists() and time.monotonic() < deadline: + time.sleep(0.05) + self.assertTrue(hanging.exists(), "bputil never started") + # Ctrl-C reaches the terminal's whole foreground process group. + os.killpg(process.pid, signal.SIGINT) + out, _ = process.communicate(timeout=20) + self.assertEqual(process.returncode, 130) + self.assertIn("Stopped.", out) + time.sleep(0.5) + with self.assertRaises(ProcessLookupError): + os.killpg(process.pid, 0) + self.assert_nothing_left_in_tmp() - def test_a_wrong_password_is_named_without_bputils_disclaimer(self): - out, calls, _ = self.run_step2(PROMPTING_KMUTIL, bputil_fails_once=True) - self.assertIn("That password didn't work for scott. Try again.", out) - self.assertNotIn("Use at your own risk", out) - # Each attempt says what it is doing while bputil works. - self.assertEqual(out.count("Updating Omarchy's security settings..."), 2) - self.assertEqual(calls, 1) + def test_an_unpaired_recovery_blesses_with_the_known_owner(self): + result, kmutil_calls, calls = self.run_step2(PROMPTING_KMUTIL, stdin="secret\n\n", paired=False) + self.assertEqual(result.returncode, 1) + self.assertIn("Password for scott:", result.stdout) + root = calls.parent + # First without a password, then with the one typed. + attempt = f"--setBoot --mount {root / 'Omarchy'} --user scott --stdinpass" + self.assertEqual(Path(f"{calls}.bless").read_text().splitlines(), [attempt, "", attempt, "secret"]) + self.assertEqual(Path(f"{calls}.boot").read_text(), "/dev/omarchy\n") + self.assertIn("choose Omarchy, and log in.", result.stdout) + self.assertEqual(kmutil_calls, 0) + + def test_an_unpaired_recovery_checks_the_startup_disk_not_bless(self): + # bless exits 0 after a wrong password, so only the startup disk tells. + result, _, calls = self.run_step2("exit 0", stdin="wrong\nsecret\n\n", paired=False) + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout.count("That password didn't work for scott. Try again."), 1) + self.assertEqual(Path(f"{calls}.boot").read_text(), "/dev/omarchy\n") + + def test_an_unpaired_recovery_lets_bless_ask_after_three_failures(self): + result, _, calls = self.run_step2("exit 0", stdin="a\nb\nc\n\n", paired=False) + self.assertEqual(result.returncode, 1) + self.assertIn("macOS asks itself now", result.stdout) + self.assertEqual(Path(f"{calls}.bless").read_text().splitlines()[-1], + f"--setBoot --mount {calls.parent / 'Omarchy'}") + self.assertEqual(Path(f"{calls}.boot").read_text(), "/dev/omarchy\n") + + def test_an_unpaired_recovery_asks_nothing_when_bless_needs_no_password(self): + # recoveryOS's bless set the startup disk after a wrong password on + # macOS 26.6.2, so step2.sh tries it without one first. + result, _, calls = self.run_step2("exit 0", stdin="\n", paired=False, bless_checks=False) + self.assertEqual(result.returncode, 1) + self.assertNotIn("Password for", result.stdout) + self.assertEqual(Path(f"{calls}.boot").read_text(), "/dev/omarchy\n") + self.assertIn("choose Omarchy, and log in.", result.stdout) + + def test_an_unpaired_recovery_already_starting_omarchy_asks_nothing(self): + result, _, calls = self.run_step2("exit 0", stdin="\n", paired=False, startup="omarchy") + self.assertEqual(result.returncode, 1) + self.assertIn("is already the", result.stdout) + self.assertNotIn("Password for", result.stdout) + self.assertFalse(Path(f"{calls}.bless").exists()) def test_every_line_fits_an_80_column_terminal(self): text = self.make("scott").read_text() @@ -974,17 +1127,6 @@ def test_every_line_fits_an_80_column_terminal(self): shown = shown.replace("$os_name", "Omarchy").replace("\\n", "") self.assertLessEqual(len(shown), 76, shown) - def test_an_unpaired_recovery_blesses_with_the_known_owner(self): - result, calls, root = self.run_step2(PROMPTING_KMUTIL, paired=False, stdin="secret\n\n") - self.assertEqual(result.returncode, 1) - self.assertIn("Password for scott:", result.stdout) - self.assertEqual( - Path(f"{calls}.bless").read_text().split("\n")[:2], - [f"--setBoot --mount {root / 'Omarchy'} --user scott --stdinpass", "secret"], - ) - self.assertIn("choose Omarchy, and log in.", result.stdout) - self.assertFalse(calls.exists()) - def test_an_unknown_owner_is_asked_for(self): text = self.make("").read_text() self.assertIn('OWNER=""', text) diff --git a/Engine/overlay/tests/test_omarchy_runtime.py b/Engine/overlay/tests/test_omarchy_runtime.py index 82f8c9e..afaf60d 100644 --- a/Engine/overlay/tests/test_omarchy_runtime.py +++ b/Engine/overlay/tests/test_omarchy_runtime.py @@ -70,12 +70,22 @@ def test_install_requires_the_complete_helper_environment(self): } ) + def test_a_released_app_without_the_installer_name_still_runs(self): + # Apps released before OMARCHY_INSTALLER_NAME must keep working with + # a newer engine; the Recovery setup then takes its title from DISTRO. + for mode in ("install", "retry-recovery-authorization"): + environment = self._install_environment() + environment["OMARCHY_ENGINE_MODE"] = mode + del environment["OMARCHY_INSTALLER_NAME"] + with self.subTest(mode=mode): + runtime = EngineRuntime.from_environment(environment) + self.assertEqual(runtime.mode, mode) + def test_install_requires_owner_and_recovery_branding(self): for key in ( "OMARCHY_MACHINE_OWNER", "DISTRO", "DISTRO_DOCS", - "OMARCHY_INSTALLER_NAME", ): environment = self._install_environment() del environment[key] diff --git a/Engine/source-lock.json b/Engine/source-lock.json index 424e210..f823969 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -171,7 +171,7 @@ { "path": "overlay/src/omarchy_asahi.py", "destination": "src/omarchy_asahi.py", - "sha256": "830fc9140d3aba975d62e74144330aba8d7396de7af4b958a4df4734fc758dc0" + "sha256": "b973d0f189ff49ded3bf8a984f95f7eb3d33d79dd4181ebe591dd23d7d1e2b21" }, { "path": "overlay/src/omarchy_contract.py", @@ -196,7 +196,7 @@ { "path": "overlay/src/omarchy_runtime.py", "destination": "src/omarchy_runtime.py", - "sha256": "001828ec63e3d5c3c437fd6f719ef717e1f8df95ff3771c13bd341c08a3ef73b" + "sha256": "5e03ee9795d3651055dfd3ed24ae55658e949c85ffd029d99bc2713671965aae" }, { "path": "overlay/src/omarchy_stage1.py", @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "4ebbed0c46a1e31ff7004f1cb152c7bf8f5735ffe4b146db43763bddb15a18ec" + "sha256": "1192980f36da8f34246bcbabb8adf516fb65770dd27bc35b0bc58303988b172b" }, { "path": "overlay/tests/test_omarchy_contract.py", @@ -236,7 +236,7 @@ { "path": "overlay/tests/test_omarchy_runtime.py", "destination": "tests/test_omarchy_runtime.py", - "sha256": "ae4e9336afa7dab09407467a4f886206c0c72e5ef3db22ff9ae962b83119043b" + "sha256": "94302801fed80159a9369ec7963ee073d2181d843a70642f489c6d48018ea6b0" }, { "path": "overlay/tests/test_omarchy_stage1.py", From 1cd231f984bd66cd81b83901d3c1de1cc9694211 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Fri, 2 Oct 2026 23:01:29 -0400 Subject: [PATCH 3/9] Ask for the password on the wrong Recovery instead of trying without one The previous commit tried bless with an empty password first, because recoveryOS's bless ignores the credentials it asks for. It needs a non-empty one, and sending a made-up password to work around Apple's check isn't something to rely on. On the wrong Recovery, step 2 now asks for the password once, as before, and still checks bless --getBoot rather than bless's exit status. Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/src/omarchy_asahi.py | 5 ----- Engine/overlay/tests/test_omarchy_asahi.py | 24 +++++++--------------- Engine/source-lock.json | 4 ++-- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index 1974bbc..17a218d 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -172,11 +172,6 @@ class AsahiAdapterError(RuntimeError): else echo "This step needs $os_name's own Recovery. Making $os_name the startup" echo "disk so that the next start opens it." - # After the Recovery login, recoveryOS's bless has set the startup disk - # without checking the password (macOS 26.6.2), so try that first. - printf '\\n' | bless --setBoot --mount "$system_dir" --user "$OWNER" --stdinpass >/tmp/bless.log 2>&1 || true - fi - if ! omarchy_is_startup; then echo # bless takes the known owner and the password on stdin. It exits 0 # even when it rejects the password, so check the startup disk instead. diff --git a/Engine/overlay/tests/test_omarchy_asahi.py b/Engine/overlay/tests/test_omarchy_asahi.py index f637698..a77098f 100644 --- a/Engine/overlay/tests/test_omarchy_asahi.py +++ b/Engine/overlay/tests/test_omarchy_asahi.py @@ -894,7 +894,7 @@ def test_the_setup_is_branded_and_asks_for_the_password_once(self): self.assertEqual(result.returncode, 0) def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password="secret", - startup="macos", bless_checks=True): + startup="macos"): """step2.sh in place, with fake recoveryOS tools first on PATH.""" step2 = self.make("scott") root = Path(step2).parent @@ -936,8 +936,7 @@ def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password= 'echo "$*" >>"$CALLS.bless"', 'case "$*" in', '*--stdinpass) IFS= read -r pw; echo "$pw" >>"$CALLS.bless"', - ' { [ "$BLESS_CHECKS" = no ] || [ "$pw" = "$EXPECTED_PASSWORD" ]; } &&' - ' echo /dev/omarchy >"$CALLS.boot" ;;', + ' [ "$pw" = "$EXPECTED_PASSWORD" ] && echo /dev/omarchy >"$CALLS.boot" ;;', # Without --stdinpass, bless asks the owner itself. '*) echo /dev/omarchy >"$CALLS.boot" ;;', 'esac', @@ -958,8 +957,7 @@ def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password= calls = root / "kmutil-calls" Path(f"{calls}.boot").write_text("/dev/omarchy\n" if startup == "omarchy" else "/dev/disk0s2\n") env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}", CALLS=str(calls), - PIPED=str(root / "piped"), EXPECTED_PASSWORD=password, BPUTIL_MODE=bputil_mode, - BLESS_CHECKS="yes" if bless_checks else "no") + PIPED=str(root / "piped"), EXPECTED_PASSWORD=password, BPUTIL_MODE=bputil_mode) return script, env, calls def run_step2(self, kmutil_body, stdin="y\nsecret\n\n", **options): @@ -1079,9 +1077,10 @@ def test_an_unpaired_recovery_blesses_with_the_known_owner(self): self.assertEqual(result.returncode, 1) self.assertIn("Password for scott:", result.stdout) root = calls.parent - # First without a password, then with the one typed. - attempt = f"--setBoot --mount {root / 'Omarchy'} --user scott --stdinpass" - self.assertEqual(Path(f"{calls}.bless").read_text().splitlines(), [attempt, "", attempt, "secret"]) + self.assertEqual( + Path(f"{calls}.bless").read_text().splitlines(), + [f"--setBoot --mount {root / 'Omarchy'} --user scott --stdinpass", "secret"], + ) self.assertEqual(Path(f"{calls}.boot").read_text(), "/dev/omarchy\n") self.assertIn("choose Omarchy, and log in.", result.stdout) self.assertEqual(kmutil_calls, 0) @@ -1101,15 +1100,6 @@ def test_an_unpaired_recovery_lets_bless_ask_after_three_failures(self): f"--setBoot --mount {calls.parent / 'Omarchy'}") self.assertEqual(Path(f"{calls}.boot").read_text(), "/dev/omarchy\n") - def test_an_unpaired_recovery_asks_nothing_when_bless_needs_no_password(self): - # recoveryOS's bless set the startup disk after a wrong password on - # macOS 26.6.2, so step2.sh tries it without one first. - result, _, calls = self.run_step2("exit 0", stdin="\n", paired=False, bless_checks=False) - self.assertEqual(result.returncode, 1) - self.assertNotIn("Password for", result.stdout) - self.assertEqual(Path(f"{calls}.boot").read_text(), "/dev/omarchy\n") - self.assertIn("choose Omarchy, and log in.", result.stdout) - def test_an_unpaired_recovery_already_starting_omarchy_asks_nothing(self): result, _, calls = self.run_step2("exit 0", stdin="\n", paired=False, startup="omarchy") self.assertEqual(result.returncode, 1) diff --git a/Engine/source-lock.json b/Engine/source-lock.json index f823969..f45fd25 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -171,7 +171,7 @@ { "path": "overlay/src/omarchy_asahi.py", "destination": "src/omarchy_asahi.py", - "sha256": "b973d0f189ff49ded3bf8a984f95f7eb3d33d79dd4181ebe591dd23d7d1e2b21" + "sha256": "94058866967b2502fcbec252e143ac057cc4c1e6854dfe20226e88bcfe62bf1e" }, { "path": "overlay/src/omarchy_contract.py", @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "1192980f36da8f34246bcbabb8adf516fb65770dd27bc35b0bc58303988b172b" + "sha256": "094a2aa400b4c43ed7998b128771a65c2207c6bcd327428e8df69606c292cd1f" }, { "path": "overlay/tests/test_omarchy_contract.py", From f8c28d927ef748769059fc0c0be2d41d8941dbbb Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Fri, 2 Oct 2026 23:14:36 -0400 Subject: [PATCH 4/9] Keep the Recovery step POSIX, and test it in the shell macOS uses CI runs on Linux, where /bin/sh is dash, and failed: a bare `read` is a bash extension that dash rejects ("read: arg count"). macOS runs step2.sh's reads become `read -r _`. The SystemVersion rename inherited from upstream used brace expansion, which a POSIX sh leaves unexpanded, so it now names both paths. The step 2 tests now run the script with `bash --posix`, as macOS and its recoveryOS do, and again under dash when it is installed (on CI, and as /bin/dash on macOS), so neither shell is tested by accident. Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/src/omarchy_asahi.py | 15 ++++++++------- Engine/overlay/tests/test_omarchy_asahi.py | 16 +++++++++++++--- Engine/source-lock.json | 4 ++-- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index 17a218d..fbb871d 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -188,7 +188,7 @@ class AsahiAdapterError(RuntimeError): echo "macOS asks itself now. Type your user name and password." until bless --setBoot --mount "$system_dir" && omarchy_is_startup; do echo "That didn't work. Press Enter to try again." - read + read -r _ done break fi @@ -200,7 +200,7 @@ class AsahiAdapterError(RuntimeError): echo echo "Press Enter to shut down. Then hold the power button until you see" echo "'Loading startup options', choose $os_name, and log in." - read + read -r _ shutdown -h now exit 1 fi @@ -210,7 +210,7 @@ class AsahiAdapterError(RuntimeError): echo echo "Press Enter to shut down. Then hold the power button, without letting" echo "go, until you see 'Loading startup options', and choose $os_name." - read + read -r _ shutdown -h now exit 1 fi @@ -246,7 +246,7 @@ class AsahiAdapterError(RuntimeError): echo "macOS asks itself now. Type your user name and password." while ! bputil -nc -v "$VGID"; do echo "That didn't work. Press Enter to try again." - read + read -r _ done break fi @@ -316,7 +316,7 @@ class AsahiAdapterError(RuntimeError): echo "macOS asks once more. Type y, then your user name and password." while ! kmutil configure-boot -c boot.bin --raw --entry-point 2048 --lowest-virtual-address 0 -v "$system_dir"; do echo "That didn't work. Press Enter to try again." - read + read -r _ done fi @@ -325,13 +325,14 @@ class AsahiAdapterError(RuntimeError): mv "$system_dir/.IAPhysicalMedia" "$system_dir/IAPhysicalMedia-disabled.plist" fi if [ -e "$system_dir/System/Library/CoreServices/SystemVersion-disabled.plist" ]; then - mv -f "$system_dir/System/Library/CoreServices/SystemVersion"{-disabled,}".plist" + mv -f "$system_dir/System/Library/CoreServices/SystemVersion-disabled.plist" \\ + "$system_dir/System/Library/CoreServices/SystemVersion.plist" fi cleanup echo echo "${BOLD}Done.${RST} Press Enter to restart into $os_name." -read +read -r _ reboot """ diff --git a/Engine/overlay/tests/test_omarchy_asahi.py b/Engine/overlay/tests/test_omarchy_asahi.py index a77098f..8b6925c 100644 --- a/Engine/overlay/tests/test_omarchy_asahi.py +++ b/Engine/overlay/tests/test_omarchy_asahi.py @@ -840,6 +840,9 @@ class Step2ScriptTests(unittest.TestCase): """The Recovery setup: the installer's own name and one password prompt.""" title = "Probe Installer" + # How macOS and its recoveryOS run step2.sh's #!/bin/sh: bash in POSIX + # mode. (Linux's /bin/sh is often dash, which the class below covers.) + shell = [shutil.which("bash") or "/bin/bash", "--posix"] def make(self, owner): root = tempfile.TemporaryDirectory() @@ -890,7 +893,7 @@ def test_the_setup_is_branded_and_asks_for_the_password_once(self): # The recoveryOS checks stay. self.assertIn("': Paired'", text) self.assertIn("'one true recoveryOS'", text) - result = subprocess.run(["bash", "-n", str(step2)]) + result = subprocess.run([*self.shell, "-n", str(step2)]) self.assertEqual(result.returncode, 0) def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password="secret", @@ -962,7 +965,7 @@ def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password= def run_step2(self, kmutil_body, stdin="y\nsecret\n\n", **options): script, env, calls = self.step2_with_fakes(kmutil_body, **options) - result = subprocess.run(["/bin/sh", str(script)], input=stdin, env=env, + result = subprocess.run([*self.shell, str(script)], input=stdin, env=env, capture_output=True, text=True, timeout=60) kmutil_calls = int(calls.read_text()) if calls.exists() else 0 return result, kmutil_calls, calls @@ -1052,7 +1055,7 @@ def test_a_kmutil_that_stalls_is_stopped_before_the_owner_answers(self): def test_ctrl_c_stops_everything_it_started(self): import signal script, env, calls = self.step2_with_fakes("exit 0", bputil_mode="hang") - process = subprocess.Popen(["/bin/sh", str(script)], env=env, stdin=subprocess.PIPE, + process = subprocess.Popen([*self.shell, str(script)], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True) process.stdin.write("y\nsecret\n") @@ -1139,5 +1142,12 @@ def test_the_template_has_only_its_four_placeholders(self): ) + +@unittest.skipUnless(shutil.which("dash"), "dash is not installed") +class Step2ScriptDashTests(Step2ScriptTests): + """The same under dash: step2.sh is #!/bin/sh, so it must stay POSIX.""" + + shell = [shutil.which("dash") or "dash"] + if __name__ == "__main__": unittest.main() diff --git a/Engine/source-lock.json b/Engine/source-lock.json index f45fd25..0f166e3 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -171,7 +171,7 @@ { "path": "overlay/src/omarchy_asahi.py", "destination": "src/omarchy_asahi.py", - "sha256": "94058866967b2502fcbec252e143ac057cc4c1e6854dfe20226e88bcfe62bf1e" + "sha256": "addaef93073711d9d0eca39151b2eb4b5bd3d2d51542a5337a2c382f4816c236" }, { "path": "overlay/src/omarchy_contract.py", @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "094a2aa400b4c43ed7998b128771a65c2207c6bcd327428e8df69606c292cd1f" + "sha256": "82b825eee047b987a8e99b07961abe0d05f1256c7be80d0c2304c2c2456835df" }, { "path": "overlay/tests/test_omarchy_contract.py", From 23cf1b35b1bdf01ab92b2722cd993780b58864c0 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Sat, 3 Oct 2026 07:34:59 -0400 Subject: [PATCH 5/9] Never type a rejected password into kmutil, and tighten the review's nits From review: after three rejected passwords, bputil asks the owner itself, but PASSWORD still held the third rejected one; the hidden kmutil typed it, failed, and the owner waited a minute for a fourth failed login. The password is now cleared once bputil asks for itself, and without one step 2 goes straight to kmutil's own prompts. Also from review: the volume group check matches only diskutil's "APFS Volume Group" line; cleanup signals the recorded PID only while it is still kmutil configure-boot, never a process that has reused the ID; the trap comment says how kmutil is stopped; and the volume group and Preboot volume group must be UUIDs before they go into the script, like the owner and the title. New tests cover Ctrl-C at the password prompt (echo is restored) and during a hidden kmutil that ignores it (it is stopped). Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/src/omarchy_asahi.py | 22 +++++- Engine/overlay/tests/test_omarchy_asahi.py | 88 +++++++++++++++++++--- Engine/source-lock.json | 4 +- 3 files changed, 98 insertions(+), 16 deletions(-) diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index fbb871d..12e1d24 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -27,6 +27,7 @@ MAXIMUM_PASSWORD_BYTES = 1_024 MACHINE_OWNER_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,255}$") INSTALLER_TITLE_PATTERN = re.compile(r"^[A-Za-z0-9 ._()-]{1,64}$") +VOLUME_GROUP_PATTERN = re.compile(r"^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$") PARTITION_PATTERN = re.compile(r"^disk[0-9]+s[0-9]+$") READBACK_CHUNK_BYTES = 1024 * 1024 @@ -62,10 +63,17 @@ class AsahiAdapterError(RuntimeError): /tmp/kmutil.log /tmp/kmutil.status /tmp/kmutil.pid /tmp/kmutil.done" spinner_pid="" -# Stop the process whose ID is in file $1, and wait until it is gone. +# Stop the kmutil whose ID is in file $1, and wait until it is gone. A process +# that has since reused the ID is left alone. stop_pid_in() { [ -s "$1" ] || return 0 pid="$(cat "$1")" + if command -v ps >/dev/null 2>&1; then + case "$(ps -p "$pid" -o args= 2>/dev/null)" in + *kmutil*configure-boot*) ;; + *) return 0 ;; + esac + fi kill "$pid" 2>/dev/null || return 0 waited=0 while kill -0 "$pid" 2>/dev/null; do @@ -90,7 +98,8 @@ class AsahiAdapterError(RuntimeError): rm -f $LOGS } trap cleanup EXIT -# bputil and kmutil run in the foreground, so Ctrl-C reaches them too. +# bputil runs in the foreground, so Ctrl-C reaches it. kmutil runs under +# script on a hidden terminal; cleanup stops it through its PID file. trap 'echo; echo "Stopped. Run this again to finish setting up $os_name."; exit 130' INT TERM HUP # Spin after the current line until file $1 exists. After $2 seconds, stop @@ -161,7 +170,7 @@ class AsahiAdapterError(RuntimeError): omarchy_is_startup() { boot="$(bless --getBoot 2>/dev/null)" || return 1 [ -n "$boot" ] || return 1 - diskutil info "$boot" 2>/dev/null | grep -q "$VGID" && return 0 + diskutil info "$boot" 2>/dev/null | grep -Eiq "^ *APFS Volume Group: +$VGID *\\$" && return 0 [ "$boot" = "$(diskutil info "$system_dir" 2>/dev/null | sed -n 's/^ *Device Node: *//p')" ] } @@ -248,6 +257,8 @@ class AsahiAdapterError(RuntimeError): echo "That didn't work. Press Enter to try again." read -r _ done + # The typed password was rejected: never offer it to kmutil. + PASSWORD="" break fi echo "That password didn't work for $OWNER. Try again." @@ -302,7 +313,7 @@ class AsahiAdapterError(RuntimeError): } } kmutil_ok=no -if command -v script >/dev/null 2>&1; then +if [ -n "$PASSWORD" ] && command -v script >/dev/null 2>&1; then rm -f "$kmutil_log" /tmp/kmutil.pid if run_with_spinner "$kmutil_status" 60 /tmp/kmutil.pid hidden_kmutil; then kmutil_ok=yes @@ -347,6 +358,9 @@ def _write_step2(installer): ) if INSTALLER_TITLE_PATTERN.fullmatch(title) is None: raise AsahiAdapterError("installer name is invalid for the Recovery setup") + for name, value in (("volume group", installer.osi.vgid), ("Preboot volume group", installer.osi.preboot_vgid)): + if VOLUME_GROUP_PATTERN.fullmatch(value or "") is None: + raise AsahiAdapterError(f"{name} is invalid for the Recovery setup") script = ( STEP2_SCRIPT.replace("##TITLE##", title) .replace("##VGID##", installer.osi.vgid) diff --git a/Engine/overlay/tests/test_omarchy_asahi.py b/Engine/overlay/tests/test_omarchy_asahi.py index 8b6925c..5275e84 100644 --- a/Engine/overlay/tests/test_omarchy_asahi.py +++ b/Engine/overlay/tests/test_omarchy_asahi.py @@ -52,10 +52,10 @@ def __init__(self, sysinfo, dutil, osinfo): self.calls = [] self.recovery = tempfile.TemporaryDirectory() self.osi = SimpleNamespace( - vgid="vgid-1", + vgid="11111111-2222-3333-4444-555555555555", sys_volume="System", recovery=self.recovery.name, - preboot_vgid="preboot-1", + preboot_vgid="66666666-7777-8888-9999-AAAAAAAAAAAA", ) # Where the real stub writes the Recovery setup that Omarchy replaces. self.step2_sh = os.path.join(self.recovery.name, "step2.sh") @@ -212,7 +212,7 @@ def test_free_extent_runs_exact_upstream_stage_one_primitives(self): target_evidence["partition_identifier"], "disk0s4", ) - self.assertEqual(installed_evidence["apfs_vgid"], "vgid-1") + self.assertEqual(installed_evidence["apfs_vgid"], "11111111-2222-3333-4444-555555555555") self.assertEqual(installed_evidence["efi_partition"], "efi-uuid") self.assertEqual( installed_evidence["startup_volume_icon"], @@ -852,7 +852,7 @@ def make(self, owner): class Stub: def __init__(self, *args): - self.osi = SimpleNamespace(vgid="VG-1", preboot_vgid="PB-1", recovery=root.name) + self.osi = SimpleNamespace(vgid="0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0", preboot_vgid="1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F809", recovery=root.name) self.step2_sh = str(step2) def load_identity(self): @@ -878,8 +878,8 @@ def test_the_setup_is_branded_and_asks_for_the_password_once(self): self.assertIn("${BOLD}Probe Installer${RST}", text) self.assertNotIn("MX Mac", text) self.assertEqual(re.findall(r"##[A-Z]+##", text), []) - self.assertIn('VGID="VG-1"', text) - self.assertIn('PREBOOT="PB-1"', text) + self.assertIn('VGID="0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0"', text) + self.assertIn('PREBOOT="1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F809"', text) self.assertIn('OWNER="scott"', text) # One prompt, kept whole: leading and trailing spaces are part of it. self.assertEqual(text.count("read -r PASSWORD"), 1) @@ -923,7 +923,7 @@ def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password= '[ "$BPUTIL_MODE" = fail ] && exit 1', "exit 0", )), - "stty": "exit 0", + "stty": 'echo "$*" >>"$CALLS.stty"', "mount": "exit 0", "reboot": "exit 0", "shutdown": "exit 0", @@ -948,7 +948,7 @@ def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password= "diskutil": "\n".join(( '[ "$1" = info ] || exit 0', 'case "$2" in', - "/dev/omarchy|*/Omarchy) printf ' Device Node: /dev/omarchy\\n APFS Volume Group: VG-1\\n' ;;", + "/dev/omarchy|*/Omarchy) printf ' Device Node: /dev/omarchy\\n APFS Volume Group: 0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0\\n' ;;", "*) printf ' Device Node: /dev/disk0s2\\n APFS Volume Group: MACOS-VG\\n' ;;", 'esac', )), @@ -1028,7 +1028,7 @@ def test_bputil_lets_macos_ask_after_three_failures(self): self.assertIn("macOS asks itself now", result.stdout) attempts = Path(f"{calls}.bputil").read_text().splitlines() self.assertEqual(len(attempts), 4) - self.assertEqual(attempts[-1], "-nc -v VG-1") + self.assertEqual(attempts[-1], "-nc -v 0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0") def test_a_kmutil_that_fails_is_handed_to_the_owner(self): started = time.monotonic() @@ -1042,8 +1042,9 @@ def test_a_kmutil_that_fails_is_handed_to_the_owner(self): self.assertLess(time.monotonic() - started, 8) def test_a_kmutil_that_stalls_is_stopped_before_the_owner_answers(self): + # It keeps its own command line, as kmutil does, so it can be recognized. result, kmutil_calls, calls = self.run_step2( - '[ "$n" -gt 1 ] && exit 0\necho $$ >"$CALLS.stalled"\nexec ' + shutil.which("sleep") + " 30") + '[ "$n" -gt 1 ] && exit 0\necho $$ >"$CALLS.stalled"\nwhile :; do sleep 1; done') self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(kmutil_calls, 2) self.assertIn("Type y, then your user name and password", result.stdout) @@ -1075,6 +1076,73 @@ def test_ctrl_c_stops_everything_it_started(self): os.killpg(process.pid, 0) self.assert_nothing_left_in_tmp() + def test_a_rejected_password_never_reaches_kmutil(self): + # After three rejected passwords bputil asks macOS itself; the third + # rejected one must not then be typed into kmutil. + started = time.monotonic() + result, kmutil_calls, calls = self.run_step2("exit 0", stdin="y\nw1\nw2\nw3\n\n", bputil_mode="fail") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(kmutil_calls, 1) + self.assertFalse(Path(f"{calls}.kmutil-log").exists(), "the hidden kmutil ran") + self.assertIn("Type y, then your user name and password", result.stdout) + self.assertLess(time.monotonic() - started, 8) + + def interrupt(self, kmutil_body, started_file, stdin, **options): + """Run step2.sh, wait for `started_file`, then press Ctrl-C.""" + import signal + script, env, calls = self.step2_with_fakes(kmutil_body, **options) + process = subprocess.Popen([*self.shell, str(script)], env=env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + start_new_session=True) + process.stdin.write(stdin) + process.stdin.flush() + started = Path(f"{calls}.{started_file}") + deadline = time.monotonic() + 20 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.05) + self.assertTrue(started.exists(), f"{started_file} never appeared") + os.killpg(process.pid, signal.SIGINT) + out, _ = process.communicate(timeout=20) + self.assertEqual(process.returncode, 130) + self.assertIn("Stopped.", out) + return calls + + def test_ctrl_c_at_the_password_prompt_restores_echo(self): + # The fake stty's log appears when echo is turned off for the prompt. + calls = self.interrupt("exit 0", "stty", stdin="y\n") + self.assertEqual(Path(f"{calls}.stty").read_text().splitlines(), ["-echo", "echo"]) + self.assertFalse(Path(f"{calls}.bputil").exists()) + self.assert_nothing_left_in_tmp() + + def test_ctrl_c_during_the_hidden_kmutil_stops_it(self): + # A kmutil that ignores Ctrl-C, as one on its own hidden terminal would. + calls = self.interrupt( + "trap '' INT\necho $$ >\"$CALLS.kmutil-pid\"\nwhile :; do sleep 1; done", + "kmutil-pid", stdin="y\nsecret\n") + with self.assertRaises(ProcessLookupError): + os.kill(int(Path(f"{calls}.kmutil-pid").read_text()), 0) + self.assert_nothing_left_in_tmp() + + def test_a_volume_group_that_is_not_a_uuid_is_refused(self): + for vgid in ("", "VG-1", '0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0"; reboot; "'): + root = tempfile.TemporaryDirectory() + self.addCleanup(root.cleanup) + step2 = Path(root.name) / "step2.sh" + + class Stub: + def __init__(self, *args, vgid=vgid): + self.osi = SimpleNamespace(vgid=vgid, preboot_vgid="1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F809", + recovery=root.name) + self.step2_sh = str(step2) + + def install_files(self, cur_os): + pass + + with self.subTest(vgid=vgid), patch("omarchy_asahi.stub.StubInstaller", Stub), patch.dict( + os.environ, {"OMARCHY_MACHINE_OWNER": "scott", "OMARCHY_INSTALLER_NAME": self.title} + ), self.assertRaisesRegex(AsahiAdapterError, "volume group is invalid"): + stub_installer("sysinfo", "dutil", "osinfo").install_files("cur-os") + def test_an_unpaired_recovery_blesses_with_the_known_owner(self): result, kmutil_calls, calls = self.run_step2(PROMPTING_KMUTIL, stdin="secret\n\n", paired=False) self.assertEqual(result.returncode, 1) diff --git a/Engine/source-lock.json b/Engine/source-lock.json index 0f166e3..a728d79 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -171,7 +171,7 @@ { "path": "overlay/src/omarchy_asahi.py", "destination": "src/omarchy_asahi.py", - "sha256": "addaef93073711d9d0eca39151b2eb4b5bd3d2d51542a5337a2c382f4816c236" + "sha256": "8160ae8a0ebae94d5124d55001a55c2802dad3c4f4afee6a32f559d2b17dcfef" }, { "path": "overlay/src/omarchy_contract.py", @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "82b825eee047b987a8e99b07961abe0d05f1256c7be80d0c2304c2c2456835df" + "sha256": "7d19f057a911bf8e4efb6160478787530653d3c027da0828c54d355ac4968f12" }, { "path": "overlay/tests/test_omarchy_contract.py", From 33367369faae719652a4476abaa7ad8c16b876cd Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Sat, 3 Oct 2026 08:06:28 -0400 Subject: [PATCH 6/9] Never let kmutil outlive script, and model script faithfully in the tests CI hung in the Ctrl-C test for a hidden kmutil: the fake script ran kmutil in the foreground of the same process group, so with a kmutil that ignores SIGINT the shells waited on it and step 2's cleanup never ran. The real script gives kmutil its own session on a pseudo-terminal, where Ctrl-C ends script but never reaches kmutil. The fake now does the same: kmutil runs in the background, where SIGINT is ignored, and script exits on SIGINT. Step 2 also closes the gap the test exposed: once script returns, any kmutil still running under the recorded ID is stopped before the ID is forgotten, so none can outlive its script. Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/src/omarchy_asahi.py | 3 ++- Engine/overlay/tests/test_omarchy_asahi.py | 13 +++++++++++-- Engine/source-lock.json | 4 ++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index 12e1d24..84fa925 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -306,7 +306,8 @@ class AsahiAdapterError(RuntimeError): script -q -t 0 "$kmutil_log" \\ /bin/sh -c 'echo $$ >/tmp/kmutil.pid; exec kmutil configure-boot -c boot.bin --raw --entry-point 2048 --lowest-virtual-address 0 -v "$1"' \\ sh "$system_dir" >/dev/null 2>&1 || status=$? - # kmutil has exited: never signal its process ID again. + # kmutil must not outlive script: stop it if it did, then forget its ID. + stop_pid_in /tmp/kmutil.pid rm -f /tmp/kmutil.pid touch /tmp/kmutil.done exit "$status" diff --git a/Engine/overlay/tests/test_omarchy_asahi.py b/Engine/overlay/tests/test_omarchy_asahi.py index 5275e84..8b837b3 100644 --- a/Engine/overlay/tests/test_omarchy_asahi.py +++ b/Engine/overlay/tests/test_omarchy_asahi.py @@ -928,8 +928,17 @@ def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password= "reboot": "exit 0", "shutdown": "exit 0", # script -q -t 0 log command...: the command's output goes to the - # log, which is kept for the test because step2.sh deletes it. - "script": 'log=$4; shift 4; "$@" >"$log" 2>&1; s=$?; cp "$log" "$CALLS.kmutil-log"; exit $s', + # log, which is kept for the test because step2.sh deletes it. Like + # the real script, which gives kmutil its own session on a hidden + # terminal, Ctrl-C ends script but never reaches kmutil: here + # kmutil runs in the background, where SIGINT is ignored. + "script": "\n".join(( + 'log=$4; shift 4', + "trap 'exit 130' INT", + '"$@" <&0 >"$log" 2>&1 &', + 'wait $!; s=$?', + 'cp "$log" "$CALLS.kmutil-log"; exit $s', + )), # Every sleep lasts 50 ms, so the one-minute deadline is twelve seconds. "sleep": f"exec {sleep} 0.05", # Like macOS's bless, it exits 0 whatever the password, and only diff --git a/Engine/source-lock.json b/Engine/source-lock.json index a728d79..05a6108 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -171,7 +171,7 @@ { "path": "overlay/src/omarchy_asahi.py", "destination": "src/omarchy_asahi.py", - "sha256": "8160ae8a0ebae94d5124d55001a55c2802dad3c4f4afee6a32f559d2b17dcfef" + "sha256": "a5d887b1d07a1977ff397980d247c9138a61cfffb675ce51aa18d07589030f0c" }, { "path": "overlay/src/omarchy_contract.py", @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "7d19f057a911bf8e4efb6160478787530653d3c027da0828c54d355ac4968f12" + "sha256": "06d68388e85330e8d1a82abb0a72d71f7e0a93f0ad1394aa33951f169dbe6288" }, { "path": "overlay/tests/test_omarchy_contract.py", From 63c4abfd65b92fb589126a7fb77e953175817da6 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Sat, 3 Oct 2026 08:35:16 -0400 Subject: [PATCH 7/9] Feed the fake kmutil through a spare descriptor, and run fakes under dash CI failed on Linux in the three tests that answer kmutil through the fake script: it ran kmutil in the background with <&0, but dash still replaces a background job's stdin with /dev/null, so the fake kmutil read empty answers. Its input now comes through descriptor 3. The dash test class also runs the fake recoveryOS tools under dash, so this fails locally as it did on CI; the step 2 script itself is unchanged. Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/tests/test_omarchy_asahi.py | 13 ++++++++++--- Engine/source-lock.json | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Engine/overlay/tests/test_omarchy_asahi.py b/Engine/overlay/tests/test_omarchy_asahi.py index 8b837b3..c19dc23 100644 --- a/Engine/overlay/tests/test_omarchy_asahi.py +++ b/Engine/overlay/tests/test_omarchy_asahi.py @@ -843,6 +843,8 @@ class Step2ScriptTests(unittest.TestCase): # How macOS and its recoveryOS run step2.sh's #!/bin/sh: bash in POSIX # mode. (Linux's /bin/sh is often dash, which the class below covers.) shell = [shutil.which("bash") or "/bin/bash", "--posix"] + # The fake recoveryOS tools' interpreter. + fake_shell = "/bin/sh" def make(self, owner): root = tempfile.TemporaryDirectory() @@ -931,11 +933,14 @@ def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password= # log, which is kept for the test because step2.sh deletes it. Like # the real script, which gives kmutil its own session on a hidden # terminal, Ctrl-C ends script but never reaches kmutil: here - # kmutil runs in the background, where SIGINT is ignored. + # kmutil runs in the background, where SIGINT is ignored. Its + # answers come through descriptor 3, since dash replaces a + # background job's stdin with /dev/null even after <&0. "script": "\n".join(( 'log=$4; shift 4', "trap 'exit 130' INT", - '"$@" <&0 >"$log" 2>&1 &', + 'exec 3<&0', + '"$@" <&3 3<&- >"$log" 2>&1 &', 'wait $!; s=$?', 'cp "$log" "$CALLS.kmutil-log"; exit $s', )), @@ -964,7 +969,7 @@ def step2_with_fakes(self, kmutil_body, paired=True, bputil_mode="ok", password= "kmutil": 'n=$(($(cat "$CALLS" 2>/dev/null || echo 0) + 1)); echo "$n" >"$CALLS"\n' + kmutil_body, } for name, body in fakes.items(): - (bin_dir / name).write_text("#!/bin/sh\n" + body + "\n") + (bin_dir / name).write_text(f"#!{self.fake_shell}\n" + body + "\n") (bin_dir / name).chmod(0o755) calls = root / "kmutil-calls" Path(f"{calls}.boot").write_text("/dev/omarchy\n" if startup == "omarchy" else "/dev/disk0s2\n") @@ -1225,6 +1230,8 @@ class Step2ScriptDashTests(Step2ScriptTests): """The same under dash: step2.sh is #!/bin/sh, so it must stay POSIX.""" shell = [shutil.which("dash") or "dash"] + # Linux's /bin/sh is often dash, so the fakes run under it too. + fake_shell = shutil.which("dash") or "dash" if __name__ == "__main__": unittest.main() diff --git a/Engine/source-lock.json b/Engine/source-lock.json index 05a6108..c448528 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "06d68388e85330e8d1a82abb0a72d71f7e0a93f0ad1394aa33951f169dbe6288" + "sha256": "bf57c80bf26bbfc00a3cb24d1a5f8fc1196e8c155789fd4af97dddc0e4b7292a" }, { "path": "overlay/tests/test_omarchy_contract.py", From a39808026ce19a07512e828abdab764aed900c4d Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Sat, 3 Oct 2026 23:51:38 -0400 Subject: [PATCH 8/9] Rebuild one engine with both overlays, as .28 The catalog's installation engine now carries #40's stub-probe fix and this branch's Recovery step: installer-v0.9.2-omarchy.28.tar.gz, 17,843,348 bytes, SHA-256 0cf1aa87..., reproduced twice with macOS /usr/bin/python3 3.9.6. Compared with .27 only omarchy_asahi.py, omarchy_runtime.py and version.tag change. The app keeps bundling .27 for inspection. Co-Authored-By: Claude Opus 5.5 --- Engine/source-lock.json | 6 +++--- docs/extraction.md | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Engine/source-lock.json b/Engine/source-lock.json index c448528..a3b34a8 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -274,9 +274,9 @@ } ], "validation_artifact": { - "filename": "installer-v0.9.2-omarchy.27.tar.gz", - "size_bytes": 17839422, - "sha256": "4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a", + "filename": "installer-v0.9.2-omarchy.28.tar.gz", + "size_bytes": 17843348, + "sha256": "0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146", "reproducibility_scope": "two-identical-python-overlay-repacks-authenticated-base", "signature": "absent", "metadata_sha256": "2e6181ce6b6e17c11039e04bfadade8d10ae0e11889885ad8c9960b68f179a5d" diff --git a/docs/extraction.md b/docs/extraction.md index 029cec0..abd9cdb 100644 --- a/docs/extraction.md +++ b/docs/extraction.md @@ -79,4 +79,6 @@ Engine `.27` stops probing stub containers. A stub carries the version of the ma Swift now withholds the resize drift margin before adopting the recommended (doubled) Omarchy size as the minimum. Adopting it first left a band of free-space states where the minimum equalled the maximum: the divider could not move and no margin remained for the engine's admission check. +The catalog's installation engine is `installer-v0.9.2-omarchy.28.tar.gz`, 17,843,348 bytes, SHA-256 `0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146`: the `.27` overlay with the Recovery step from #39 added. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes; compared with `.27`, only `omarchy_asahi.py`, `omarchy_runtime.py` and `version.tag` changed. The app keeps bundling `.27` for inspection and planning, whose planner and inventory `.28` share unchanged. + Ship installer **2.1.0 or later** together with the new engine and a signed catalog whose `installer.minimumVersion` is at least **2.1.0**. Both release-input templates carry this gate, and the catalog generator refuses a lower minimum for `.18` or newer engines in this lineage. Older installers then show the update-required message before decoding recommendation fields. The bundled inspection pins now select `.27`; signed production catalogs and frozen private-test catalogs are not changed by this source update. Artifact publication, catalog signing and physical installation qualification remain separate release steps. From 41544a29d0b5043b0ebf0427905171df1c689ff7 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Sun, 4 Oct 2026 08:24:54 -0400 Subject: [PATCH 9/9] Bundle .28 too, so the release scripts publish the catalog's engine cutover-wizard and assemble-candidate-v8.sh take the catalog engine from Packaging/build-app.sh, so with the app still pinned to .27 they would upload .27 while the templates name .28, and the catalog generator would stop on a missing asset. The packager, the Swift artifact pin and its test now select .28, whose inspection code matches .27. The docs no longer say the templates select .27. Co-Authored-By: Claude Opus 5.5 --- Packaging/build-app.sh | 4 ++-- .../OmarchyAppleInstaller/ValidationEngineArtifact.swift | 8 ++++---- .../ValidationEngineArtifactTests.swift | 8 ++++---- docs/extraction.md | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Packaging/build-app.sh b/Packaging/build-app.sh index 4cb7f38..5ef6bd3 100755 --- a/Packaging/build-app.sh +++ b/Packaging/build-app.sh @@ -46,8 +46,8 @@ helper_identifier="$INSTALLER_HELPER_IDENTIFIER" app_name="$INSTALLER_APP_NAME.app" app_executable_name="OmarchyAppleInstallerApp" daemon_plist_name="$helper_identifier.plist" -engine_file_name="installer-v0.9.2-omarchy.27.tar.gz" -engine_digest="4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a" +engine_file_name="installer-v0.9.2-omarchy.28.tar.gz" +engine_digest="0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" if [[ $signing_identity == "-" ]]; then client_requirement="identifier \"$app_identifier\"" diff --git a/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift b/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift index 9807888..0396ad7 100644 --- a/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift +++ b/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift @@ -18,11 +18,11 @@ /// An installation engine fix ships in a catalog without rebuilding /// and re-notarizing the app. Nothing may require them to be equal. public struct ValidationEngineArtifactLocator: Sendable { - public static let version = "v0.9.2-omarchy.27" - public static let fileName = "installer-v0.9.2-omarchy.27.tar.gz" + public static let version = "v0.9.2-omarchy.28" + public static let fileName = "installer-v0.9.2-omarchy.28.tar.gz" public static let expectedDigest = - "sha256:4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a" - public static let expectedSizeBytes: UInt64 = 17_839_422 + "sha256:0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" + public static let expectedSizeBytes: UInt64 = 17_843_348 public init() {} diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift index c7bb3d8..fafa9b5 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift @@ -8,19 +8,19 @@ func testM3CapableInspectionIdentityIsPinnedExactly() { XCTAssertEqual( ValidationEngineArtifactLocator.version, - "v0.9.2-omarchy.27" + "v0.9.2-omarchy.28" ) XCTAssertEqual( ValidationEngineArtifactLocator.fileName, - "installer-v0.9.2-omarchy.27.tar.gz" + "installer-v0.9.2-omarchy.28.tar.gz" ) XCTAssertEqual( ValidationEngineArtifactLocator.expectedDigest, - "sha256:4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a" + "sha256:0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" ) XCTAssertEqual( ValidationEngineArtifactLocator.expectedSizeBytes, - 17_839_422 + 17_843_348 ) } diff --git a/docs/extraction.md b/docs/extraction.md index abd9cdb..3f638f4 100644 --- a/docs/extraction.md +++ b/docs/extraction.md @@ -75,10 +75,10 @@ After merging current `main` on 2026-10-03, two repacks with `/usr/bin/python3` ## Stub probe and divider margin fixes -Engine `.27` stops probing stub containers. A stub carries the version of the macOS it was made from, so a versioned OS alone admitted it to the limits probes, and one failed probe of a stub on a Mac with an existing Asahi or Omarchy install failed the whole inventory. The filter now admits only versioned OSes that are not stubs. The pinned archive is `installer-v0.9.2-omarchy.27.tar.gz`, 17,839,422 bytes, SHA-256 `4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a`. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes from the authenticated `.14` base and locked v0.9.2 checkout; compared with `.20`, only `omarchy_runtime.py` and `version.tag` changed. The bundled inspection pins and both release-input templates select `.27`. +Engine `.27` stops probing stub containers. A stub carries the version of the macOS it was made from, so a versioned OS alone admitted it to the limits probes, and one failed probe of a stub on a Mac with an existing Asahi or Omarchy install failed the whole inventory. The filter now admits only versioned OSes that are not stubs. The pinned archive is `installer-v0.9.2-omarchy.27.tar.gz`, 17,839,422 bytes, SHA-256 `4f9241b0139ba6ccdcfdb3484831002e07e36ba50274279c641ca2020593a15a`. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes from the authenticated `.14` base and locked v0.9.2 checkout; compared with `.20`, only `omarchy_runtime.py` and `version.tag` changed. At #40, the bundled inspection pins and both release-input templates selected `.27`. Swift now withholds the resize drift margin before adopting the recommended (doubled) Omarchy size as the minimum. Adopting it first left a band of free-space states where the minimum equalled the maximum: the divider could not move and no margin remained for the engine's admission check. -The catalog's installation engine is `installer-v0.9.2-omarchy.28.tar.gz`, 17,843,348 bytes, SHA-256 `0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146`: the `.27` overlay with the Recovery step from #39 added. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes; compared with `.27`, only `omarchy_asahi.py`, `omarchy_runtime.py` and `version.tag` changed. The app keeps bundling `.27` for inspection and planning, whose planner and inventory `.28` share unchanged. +The catalog's installation engine is `installer-v0.9.2-omarchy.28.tar.gz`, 17,843,348 bytes, SHA-256 `0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146`: the `.27` overlay with the Recovery step from #39 added. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes; compared with `.27`, only `omarchy_asahi.py`, `omarchy_runtime.py` and `version.tag` changed. The bundled inspection pins and packager select `.28` too, so the release scripts, which take the catalog engine from `Packaging/build-app.sh`, publish the engine the templates name. -Ship installer **2.1.0 or later** together with the new engine and a signed catalog whose `installer.minimumVersion` is at least **2.1.0**. Both release-input templates carry this gate, and the catalog generator refuses a lower minimum for `.18` or newer engines in this lineage. Older installers then show the update-required message before decoding recommendation fields. The bundled inspection pins now select `.27`; signed production catalogs and frozen private-test catalogs are not changed by this source update. Artifact publication, catalog signing and physical installation qualification remain separate release steps. +Ship installer **2.1.0 or later** together with the new engine and a signed catalog whose `installer.minimumVersion` is at least **2.1.0**. Both release-input templates carry this gate, and the catalog generator refuses a lower minimum for `.18` or newer engines in this lineage. Older installers then show the update-required message before decoding recommendation fields. The bundled inspection pins now select `.28`; signed production catalogs and frozen private-test catalogs are not changed by this source update. Artifact publication, catalog signing and physical installation qualification remain separate release steps.