Skip to content

Ask once for the password in a quieter, renamed Recovery step - #39

Merged
maralcbr merged 10 commits into
mainfrom
mac/recovery-step
Oct 4, 2026
Merged

maralcbr merged 10 commits into
mainfrom
mac/recovery-step

Conversation

@scottjones

@scottjones scottjones commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Make the Recovery step short and quiet. On an existing install the screen is now:

Omarchy Installer

This lets Omarchy start Linux by lowering the security level of
Omarchy only. macOS keeps Full Security.

Are you sure you want to do this? (y or n) y

Password for chasjones:
Updating Omarchy's security settings... /
Installing Omarchy's boot loader... -

Done. Press Enter to restart into Omarchy.

Before, it was titled "Omarchy MX Mac installer (second step)", asked for the user name and password up to three times, showed kmutil's raw prompts and Apple's warning, and printed bputil's "Use at your own risk!" banner on a wrong password.

What changes

  • Omarchy's own step2.sh: the stub installer writes it over asahi-installer's after install_files, titled with the app's name (OMARCHY_INSTALLER_NAME), for the owner the app already knows (OMARCHY_MACHINE_OWNER).
  • One confirmation, then one password:
    • "Are you sure you want to do this? (y or n)" comes before anything changes. Any answer but y exits with "Nothing was changed".
    • bputil -nc takes the owner and the password as arguments. After three failures, bputil -nc -v asks the owner itself, as upstream does; the rejected password is then cleared, and kmutil asks the owner directly instead of being typed it. A rejected password replaces the "Updating" line with "That password didn't work for . Try again." The password is read with IFS= read -r, so leading and trailing spaces survive.
    • kmutil configure-boot has no credential options. It reads "are you sure" from stdin but the user name and password from its terminal, and discards anything typed ahead (piping all three stalls). So it runs under script on a hidden terminal, and the script types each answer once its prompt appears in the log. The password is sent with echo off, so the log never holds it.
    • If kmutil fails, or is still running after a minute, it's stopped and the owner answers it directly. A one-line wrapper records kmutil's own process ID, so the watchdog stops kmutil itself, not only script, and the fallback never overlaps it.
  • Ctrl-C and cleanup:
    • bputil and kmutil run in the foreground, so Ctrl-C reaches them. The spinner runs in the background.
    • A handler on exit, Ctrl-C, TERM and HUP stops the spinner and any hidden kmutil, restores echo, clears the password and deletes every /tmp log.
  • The wrong Recovery: a long press opens the recoveryOS paired with the default startup disk. If that isn't Omarchy's, the step makes Omarchy the startup disk and asks for a restart.
    • In recoveryOS, bless --setBoot asks for a user name and password but accepts any values and exits 0 (tested on macOS 26.6.2 with a made-up user name). So step 2 checks the result with bless --getBoot against Omarchy's volume group, not bless's exit status.
    • It asks nothing when Omarchy already is the startup disk. Otherwise it asks for the password once. It doesn't send a made-up password to get past Apple's prompt.
    • The engine's macOS-side handoff already makes the stub the startup disk before shutdown, so this path only appears if the startup disk changes afterwards.
  • Wording: every line fits an 80-column Terminal.
  • POSIX sh: step2.sh is #!/bin/sh, which macOS runs as bash in POSIX mode. It no longer uses bare read or the brace expansion upstream's SystemVersion rename relied on, so it also runs under dash.
  • App and engine runtime:
    • DISTRO is now "Omarchy" (was "Omarchy MX Mac"), and the app passes OMARCHY_INSTALLER_NAME.
    • The engine accepts its absence, so released apps keep working with the new engine; the title then falls back to DISTRO + " installer".
  • Engine v0.9.2-omarchy.28: one engine with Allow a minimum Omarchy install when the doubled size does not fit #27's planner, Fix #27's zip link, stub probe and divider margin; pin engine .27 #40's stub-probe fix and this step: 17,843,348 bytes, SHA-256 0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146. Compared with Fix #27's zip link, stub probe and divider margin; pin engine .27 #40's .27, only omarchy_asahi.py, omarchy_runtime.py and version.tag change; compared with the earlier .26, only Fix #27's zip link, stub probe and divider margin; pin engine .27 #40's omarchy_runtime.py fix and version.tag. It reproduces byte for byte with macOS /usr/bin/python3 3.9.6, the interpreter Allow a minimum Omarchy install when the doubled size does not fit #27 records in docs/extraction.md; other Python versions encode the tar headers differently. It includes #166's planner change, which .17 never shipped. The release inputs templates move to .28, keeping Allow a minimum Omarchy install when the doubled size does not fit #27's installer minimum of 2.1.0 and Fix #27's zip link, stub probe and divider margin; pin engine .27 #40's zip link. The app bundles .28 too (Packaging/build-app.sh, ValidationEngineArtifact.swift), because the release scripts take the catalog engine from that pin.

Validation

Candidate 41544a2, stacked on #40 (9787a83), on Xcode 27.0, macOS 26.6.2, M4 Pro:

  • Tests:
    • ./test/all passes (Bash 5.3).
    • swift-format lint --strict is clean.
    • swift test passes in debug and release (519 tests, 1 skipped).
    • The engine overlay tests run step2.sh itself against fake bputil, bless, diskutil, kmutil and script, under bash --posix (as macOS runs it) and again under dash (CI's /bin/sh). They cover:
      • kmutil answered unseen;
      • the password never in kmutil's log;
      • kmutil failing, and stalling, where the stalled process is gone before the fallback;
      • declining before any change;
      • a wrong password;
      • three bputil failures;
      • a password with spaces;
      • Ctrl-C leaving no process or file behind;
      • each wrong-Recovery case (already the startup disk, a wrong password, bless asking itself);
      • line widths;
      • a released app's environment.
  • Engine: .28 builds byte-identically twice, and verify-archive-modes.py and verify-source-lock.py pass.
  • Linux, as CI runs it: bash test/all passes in an Ubuntu 24.04 container (dash as /bin/sh, bash 5.2, Python 3.12). The Ctrl-C and stall tests passed 10 repeated rounds each on x86_64 and arm64 Ubuntu, and 15 on macOS.
  • script behaviour on macOS: the exact kmutil block ran under /bin/sh with the real script(1) against stand-ins.
    • One discards type-ahead the way kmutil does: it finishes, and the password is not in the log.
    • One stalls: the watchdog stops it, and nothing is left running or in /tmp.
  • Full install, M3 Air (j613, 512 GB, macOS 26.6.2), owner-run: a developer test build of this branch on Allow a minimum Omarchy install when the doubled size does not fit #27 before Fix #27's zip link, stub probe and divider margin; pin engine .27 #40 (engine .26, dde21d63…, from a dev-key catalog and locally staged assets) removed the previous Omarchy, installed, ran this Recovery step from the Mac's own recoveryOS and booted Omarchy.
  • 13-inch M3 Air (j613, macOS 26.6.2, 25G83), owner-run: step 2 from this branch was placed on an existing install and run from its own Recovery.
    • "Are you sure?" first.
    • A wrong password, replaced by the plain message.
    • Both spinners, then Done.
    • n stops before the password.
    • Ctrl-C at the password prompt, during bputil, and during kmutil each stop cleanly: no bputil, kmutil or script left, and no /tmp files.
    • Three rejected passwords (with 38509de's step 2): bputil asked for itself, then kmutil asked straight away, with no wait and no reuse of the rejected password.
    • The wrong-Recovery path, run with this branch's final step 2, starting from macOS as the startup disk (/dev/disk4s1): one password prompt, then Omarchy became the startup disk (/dev/disk2s2) and the --getBoot check recognized it.
    • bless --setBoot in Recovery switched the startup disk with a made-up user name and any password.
  • MacBook Neo (j700, macOS 26.6, 25G72): the first version of this step (single password, hidden kmutil answers) ran end to end on an existing install.

The .28 artifact still has to be published where the release inputs expect it before a catalog can name it.

Merge order

Stacked on #40, which fixes #27's post-merge findings (zip link, stub probe, divider margin). Merge #40 first; this PR's base then becomes main. The templates carry #27's 2.1.0 minimum and #40's zip link, and one .28 engine carries both overlays.

Not in this PR

  • Making step 2 open by itself after the Recovery login, instead of Utilities → Terminal and a typed path.
  • The MacBook Neo bring-up and the developer build (mac/neo-j700), which will be rebased onto this.

🤖 Generated with Claude Code

Base automatically changed from mac/helper-provisioner to main October 3, 2026 01:13
@scottjones

Copy link
Copy Markdown
Collaborator Author

Thanks, all fair. Fixed in 4c56647, and checked on the M3 from its own Recovery:

  • Security lowered before the confirmation: "Are you sure?" now comes first. n exits with "Nothing was changed" before the password prompt, and bputil never runs.
  • Ctrl-C: bputil and kmutil run in the foreground, with only the spinner in the background. A handler on EXIT/INT/TERM/HUP stops the spinner and any hidden kmutil, waits for it to exit, restores echo, clears the password and deletes the /tmp logs. On the M3, Ctrl-C at the password prompt, during bputil and during kmutil each stopped cleanly, with nothing left in ps or /tmp.
  • bputil looping forever: the 600 s spinner limit is gone. After three failures, bputil -nc -v "$VGID" asks the owner itself, as upstream did.
  • Spaces in passwords: IFS= read -r. There's a test with leading, trailing and inner spaces.
  • Released apps with .26: OMARCHY_INSTALLER_NAME is optional again for install and retry. The title falls back to DISTRO + " installer", and a test runs a released app's environment.
  • Two kmutils: script now runs /bin/sh -c 'echo $$ >/tmp/kmutil.pid; exec kmutil …'. The watchdog stops that PID (TERM, then KILL after 2 s) and waits until it's gone. script only returns once kmutil has exited, so the fallback can't overlap it. A test checks that the stalled process is gone.
  • Logs: deleted at the end and on any exit. A test checks that the password never appears in kmutil's log: the fake echoes typed input as a terminal would, except after Password:.

Two more from testing:

  • A rejected password now replaces the "Updating…" line with the reason.
  • On the wrong Recovery, bless --setBoot in recoveryOS takes any user name and password and exits 0, so its exit status means nothing. Step 2 now checks bless --getBoot against Omarchy's volume group, asks nothing if Omarchy is already the startup disk, and tries bless without a password before asking.

@scottjones

Copy link
Copy Markdown
Collaborator Author

Follow-up in baaa687: I dropped the password-less bless attempt from 4c56647. recoveryOS's bless ignores the credentials it asks for, but it rejects an empty password, and sending a made-up one to get past it isn't something to rely on. On the wrong Recovery, step 2 now asks for the password once and checks bless --getBoot against Omarchy's volume group. Checked on the M3 from macOS's Recovery: the startup disk went from disk4s1 (macOS) to disk2s2 (Omarchy) and was recognized.

@maralcbr

maralcbr commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

@scottjones thanks, this round is solid. Re-reviewed at 8a4e556: ./test/all and swift test pass locally (664, 1 skipped). Everything from the last round is fixed: the confirmation comes before anything changes, bputil and kmutil run in the foreground with the cleanup trap, bputil falls back to asking itself after three tries, IFS= read -r, OMARCHY_INSTALLER_NAME is optional again, and kmutil is stopped by its own PID before the fallback starts.

One thing before merge:

  • A rejected password is reused for kmutil (omarchy_asahi.py:245-251, then 282-285). After three rejected passwords the interactive bputil -nc succeeds, but PASSWORD still holds the third rejected one. The hidden kmutil types it, fails, and the owner waits about 60 s before being asked again, which is a fourth failed login. Clearing PASSWORD after the interactive path, and going straight to the interactive kmutil when there's no password, would fix it.

Nits, fine as follow-ups:

  • The PR description still quotes 1133c9c7…; the lock now pins 7d5ae390a7198f1fb3f7eb03c6fd58afe0f0e3a7f0a79ee40247436994ba4409. Worth updating so nobody publishes the old bytes as .26.
  • omarchy_is_startup greps diskutil info for $VGID unanchored (:164); matching the volume group line would be tighter.
  • The trap comment says kmutil runs in the foreground; it runs under script on a hidden pty, with the PID file as the backstop.
  • stop_pid_in can't tell a recycled PID from kmutil. Very unlikely in Recovery, but a command-name check would close it.
  • VGID and PREBOOT go into the script without an allowlist (owner and title have one), and the password is still in bputil's argv for the first three tries.
  • No test covers Ctrl-C at the password prompt or during the hidden kmutil, or that echo is restored afterwards.

Merge order: I'd like #27 to land first. Its catalog generator refuses an engine at .18 or above with an installer minimum below 2.1.0, and this .26 doesn't carry #27's planner. After #27, please rebase this, set the templates' minimum to 2.1.0, and rebuild one engine with both overlays.

@scottjones

Copy link
Copy Markdown
Collaborator Author

Thanks. Fixed in 38509de; checks pass on macOS and in an Ubuntu 24.04 container as CI runs them.

  • Rejected password reused for kmutil: once bputil falls back to asking the owner itself, PASSWORD is cleared. Without a password, step 2 skips the hidden kmutil and goes straight to kmutil's own prompts. A test runs three rejected passwords and checks that the hidden kmutil never runs and the owner is asked within seconds. On the M3 from its own Recovery: three wrong passwords, then bputil asked for itself and lowered the policy, and "macOS asks once more" came straight after "Installing Omarchy's boot loader...", with no minute-long spinner and no fourth failed login. kmutil then took y, the user name and the password, and finished.
  • Description digest: updated for this commit (740f7cb8…, 17,842,070 bytes).
  • omarchy_is_startup: matches only ^ *APFS Volume Group: +$VGID *$ (case-insensitive).
  • Trap comment: now says bputil runs in the foreground and kmutil runs under script, stopped through its PID file.
  • Recycled PIDs: stop_pid_in signals the recorded PID only while ps -o args= still reads kmutil … configure-boot. If ps were missing, it keeps today's behaviour.
  • VGID / PREBOOT: both must be UUIDs before they go into the script, like the owner and the title, with a test that includes an injection attempt.
  • Password in bputil's argv: left as is. bputil has no other way to take it, recoveryOS is single-user, and after the third failure it's no longer passed at all.
  • Ctrl-C tests: added for the password prompt (echo off, then restored, and bputil never runs) and during a hidden kmutil that ignores SIGINT (stopped through its PID file, nothing left in /tmp). They run under bash --posix and dash.

Merge order: understood. I'll rebase after #27, set the templates' minimum to 2.1.0 and rebuild one engine with both overlays. The plan is in the description.

@scottjones

Copy link
Copy Markdown
Collaborator Author

CI fix in 6b97334: the Ctrl-C test for a hidden kmutil hung on the x86_64 runner because the fake script ran kmutil in the foreground of the same process group. The real script gives it its own session on a pty, so Ctrl-C ends script but never reaches kmutil. The fake now does the same, and step 2 stops any kmutil still running under the recorded ID once script returns, so none can outlive it. The Ctrl-C and stall tests passed 10 repeated rounds each on x86_64 and arm64 Ubuntu and 15 on macOS. Engine .26 is now 7aee4e72….

@scottjones

Copy link
Copy Markdown
Collaborator Author

Rebased onto main after #27 merged (cf2afde), following your merge order: the release templates keep #27's 2.1.0 installer minimum, and one .26 engine carries both overlays: dde21d63…, 17,843,294 bytes, reproducible with /usr/bin/python3 3.9.6 as #27 records. It also picks up #166's planner change, which never made it into .17. A full install on the M3 from a developer test build of exactly this combination worked end to end: removal, install, this Recovery step, and Omarchy booting. ./test/all and strict lint pass locally; CI is running.

scottjones and others added 8 commits October 3, 2026 23:48
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…ests

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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@scottjones
scottjones changed the base branch from main to fix/27-zip-download-link October 4, 2026 03:51
@scottjones
scottjones requested a review from maralcbr October 4, 2026 03:58
Base automatically changed from fix/27-zip-download-link to main October 4, 2026 09:59
@maralcbr

maralcbr commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

@scottjones re-reviewed the rebase at a398080. It's clean: the step 2 script and its tests are identical to 0ee4b81, which I'd already signed off. .28 rebuilds byte-identically twice with /usr/bin/python3 3.9.6 from the locked v0.9.2 checkout (0cf1aa87…, 17,843,348 bytes, matching the lock and the description), and verify-archive-modes and verify-source-lock pass. On a scratch merge with current main, ./test/all passes and swift test runs 677 with 1 skipped. The templates carry .28, minimum 2.1.0 and the zip link, so #27's catalog gate is satisfied.

One thing before merge:

  • The release scripts would publish .27, not .28. The templates name .28, but Packaging/build-app.sh:49-50 and the bundled inspection pin stay on .27. scripts/cutover-wizard:209 reads the engine from build-app.sh, so it uploads and stages .27 (:335, :420). make-unsigned-catalog.py:327 then looks for assets/installer-v0.9.2-omarchy.28.tar.gz and stops with a missing asset. Hand-copying .28 in would give a catalog that points at an object the upload never published. scripts/assemble-candidate-v8.sh:256 reads the same pin, only warns on the mismatch (:294), and publishes .27.

    Keeping inspection on .27 is harmless for the app, but both publishers assume the bundled pin is the catalog engine. Since this PR already needs a new app build, the simplest fix is to move the bundled pin to .28 too: build-app.sh:49-50, ValidationEngineArtifact.swift:21-25 and ValidationEngineArtifactTests.swift. Teaching only the wizard to read the template would still leave assemble-candidate-v8.sh publishing .27.

Nit: docs/extraction.md:78 still says the release templates select .27.

scottjones and others added 2 commits October 4, 2026 08:24
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 <noreply@anthropic.com>

@maralcbr maralcbr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at 5b75651. The bundled inspection pin, packager and templates now all select .28 (0cf1aa87…, 17,843,348 bytes, reproduced twice with /usr/bin/python3 3.9.6), so the cutover wizard and assemble-candidate publish the engine the catalog names. The Recovery step itself is unchanged from the version reviewed at 0ee4b81. Publishing .28 and the signed catalog, and the pending hardware checks, remain before release.

@maralcbr
maralcbr merged commit 27bdf16 into main Oct 4, 2026
3 checks passed
@maralcbr
maralcbr deleted the mac/recovery-step branch October 4, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants