From 0525fc7eaf2f88c568c925c2a8e759bd207e2255 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Thu, 1 Oct 2026 14:40:40 -0400 Subject: [PATCH 01/10] Install on macOS 26 firmware and the MacBook Neo The MacBook Neo (J700, T8140) can only use macOS 26 boot firmware, which asahi-installer v0.9.2 was not written for. The engine now: - knows T8140, j700ap and a macOS 26.6 (25G72) OS-firmware entry; - names the restore bundle "./Restore" when macOS 26's bootcaches.plist no longer does; - replaces the restore image's encrypted (AEA) recoveryOS and ExclaveOS images with the running macOS's decrypted copies of the same build, which the stub's recoveryOS kernel can mount, and collects firmware the same way; - gives a stub on macOS 26 firmware 6 GB instead of 2.5 GB, room for the decrypted recoveryOS and two sets of personalized boot objects. The Python overlay repack now pins the patch the omarchy.14 base was built with (patches/base), so a base still has to reproduce exactly what it shipped while the new upstream file takes the current patch. Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/src/omarchy_asahi.py | 139 +++++++++- Engine/overlay/tests/test_omarchy_asahi.py | 245 +++++++++++++++++- .../patches/0001-omarchy-engine-runtime.patch | 56 +++- .../base/0001-omarchy-engine-runtime.patch | 245 ++++++++++++++++++ Engine/rebuild-python-overlay.py | 16 +- Engine/source-lock.json | 18 +- Engine/tests/test_rebuild_python_overlay.py | 30 ++- Engine/tests/test_verify_source_lock.py | 26 +- Engine/verify-source-lock.py | 8 +- 9 files changed, 746 insertions(+), 37 deletions(-) create mode 100644 Engine/patches/base/0001-omarchy-engine-runtime.patch diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index 84fa925..681af4a 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -30,6 +30,14 @@ 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 +# Where every macOS through 14 named its restore bundle in bootcaches.plist, +# and where macOS 26 still keeps it in Preboot, though its bless2 no longer +# names it. +RESTORE_BUNDLE_PATH = "./Restore" +# From macOS 15 the recovery image in a restore image is an Apple Encrypted +# Archive, which hdiutil cannot attach without Apple's key. +AEA_MAGIC = b"AEA1" +HOST_ROOT = "/" class AsahiAdapterError(RuntimeError): @@ -373,20 +381,145 @@ def _write_step2(installer): os.chmod(installer.step2_sh, 0o755) +class _WithoutRecoveryMount: + """subprocess for stub, minus attaching or detaching its recovery image.""" + + def __init__(self, module): + self._module = module + + def __getattr__(self, name): + return getattr(self._module, name) + + def run(self, command, *args, **kwargs): + if command[:1] == ["hdiutil"] and "recovery" in command: + return self._module.CompletedProcess(command, 0) + return self._module.run(command, *args, **kwargs) + + def stub_installer(sysinfo, dutil, osinfo): - """A StubInstaller whose Recovery setup is Omarchy's own step2.sh.""" + """A StubInstaller that reads macOS 26 firmware's bootcaches.plist and + writes Omarchy's own step2.sh as its Recovery setup.""" installer = stub.StubInstaller(sysinfo, dutil, osinfo) + load_identity = installer.load_identity + + def load_identity_naming_the_restore_bundle(): + identity = load_identity() + installer.bootcaches["bless2"].setdefault( + "RestoreBundlePath", RESTORE_BUNDLE_PATH + ) + return identity + + installer.load_identity = load_identity_naming_the_restore_bundle + + collect_firmware = installer.collect_firmware + + def collect_firmware_from_an_encrypted_recovery(pkg): + image = os.path.join( + installer.osi.recovery, + installer.osi.vgid, + "usr/standalone/firmware/arm64eBaseSystem.dmg", + ) + with open(image, "rb") as fd: + if fd.read(len(AEA_MAGIC)) != AEA_MAGIC: + return collect_firmware(pkg) + # The firmware the recovery image carries is the same build's as + # the running macOS's own /usr/share/firmware and /usr/sbin, so it is + # read from there, and only when the builds match. + wanted = installer.manifest["ProductBuildVersion"] + running = installer.sysinfo.macos_build + if running != wanted: + raise AsahiAdapterError( + f"the {wanted} recovery image is encrypted; run the installer " + f"from macOS {wanted}, not {running}, to collect its firmware" + ) + os.makedirs("recovery/usr") + module = stub.subprocess + try: + for name in ("share", "sbin"): + os.symlink( + os.path.join(HOST_ROOT, "usr", name), + os.path.join("recovery/usr", name), + ) + # Only stub's own name is rebound, never the shared module. + stub.subprocess = _WithoutRecoveryMount(module) + return collect_firmware(pkg) + finally: + stub.subprocess = module + shutil.rmtree("recovery") + + installer.collect_firmware = collect_firmware_from_an_encrypted_recovery + install_files = installer.install_files - def install_files_with_omarchys_step2(cur_os): + def install_files_with_decrypted_images_and_omarchys_step2(cur_os): result = install_files(cur_os) + _use_decrypted_images(installer, cur_os) _write_step2(installer) return result - installer.install_files = install_files_with_omarchys_step2 + installer.install_files = install_files_with_decrypted_images_and_omarchys_step2 return installer +def _is_encrypted(path): + with open(path, "rb") as fd: + return fd.read(len(AEA_MAGIC)) == AEA_MAGIC + + +def _use_decrypted_images(installer, cur_os): + """Swap the stub's AEA images for the running macOS's decrypted ones. + + A macOS 26 restore image ships its recoveryOS and ExclaveOS images + encrypted; the stub's recoveryOS kernel cannot mount them ("Failed to + mount root image"). macOS keeps the same build's images decrypted in its + own Recovery and Preboot volumes, and their signed root hashes are the + ones the stub's boot objects are personalized for. + """ + image = "usr/standalone/firmware/arm64eBaseSystem.dmg" + stub_image = os.path.join(installer.osi.recovery, installer.osi.vgid, image) + if not _is_encrypted(stub_image): + return + wanted = installer.manifest["ProductBuildVersion"] + running = installer.sysinfo.macos_build + if running != wanted: + raise AsahiAdapterError( + f"the {wanted} recovery image is encrypted; run the installer " + f"from macOS {wanted}, not {running}, to use its decrypted copy" + ) + host_image = os.path.join(cur_os.recovery, cur_os.vgid, image) + # A UDIF image ends in its 512-byte "koly" trailer. + with open(host_image, "rb") as fd: + fd.seek(0, os.SEEK_END) + trailer = b"" + if fd.tell() >= 512: + fd.seek(-512, os.SEEK_END) + trailer = fd.read(4) + if trailer != b"koly": + raise AsahiAdapterError( + "the running macOS's recovery image is not a decrypted disk image" + ) + copies = [(host_image, stub_image)] + bless2 = installer.bootcaches["bless2"] + restore = os.path.join( + installer.pb_vgid, bless2.get("RestoreBundlePath", RESTORE_BUNDLE_PATH) + ) + host_restore = os.path.join(cur_os.preboot, cur_os.vgid, "restore") + for name in sorted(os.listdir(restore)): + path = os.path.join(restore, name) + if not os.path.isfile(path) or not _is_encrypted(path): + continue + source = os.path.join(host_restore, name) + if not os.path.isfile(source) or _is_encrypted(source): + raise AsahiAdapterError( + f"the running macOS has no decrypted {name} for the stub" + ) + copies.append((source, path)) + for source, target in copies: + # Removed first: the stub container has room for one copy at a time. + os.unlink(target) + shutil.copyfile(source, target) + + class AsahiInPlaceRepairAdapter: """Non-partitioning adapter for one manifest-bound installed system.""" diff --git a/Engine/overlay/tests/test_omarchy_asahi.py b/Engine/overlay/tests/test_omarchy_asahi.py index c19dc23..7f1b592 100644 --- a/Engine/overlay/tests/test_omarchy_asahi.py +++ b/Engine/overlay/tests/test_omarchy_asahi.py @@ -7,6 +7,7 @@ from pathlib import Path from types import SimpleNamespace import sys +import subprocess import tempfile import re import shutil @@ -64,6 +65,12 @@ def __init__(self, sysinfo, dutil, osinfo): def load_ipsw(self, ipsw): self.calls.append(("load_ipsw", ipsw)) + def load_identity(self): + self.bootcaches = {"bless2": {}} + + def collect_firmware(self, pkg): + self.calls.append(("collect_firmware", pkg)) + def prepare_volume(self, part): self.calls.append(("prepare_volume", part.name)) @@ -75,6 +82,10 @@ def check_volume(self, part=None): def install_files(self, current_os): self.calls.append(("install_files", current_os)) + # As the real stub does, with the plain image older firmware ships. + image = Path(self.osi.recovery, self.osi.vgid, "usr/standalone/firmware/arm64eBaseSystem.dmg") + image.parent.mkdir(parents=True, exist_ok=True) + image.write_bytes(b"plain recovery image") def prepare_for_bless(self): self.calls.append(("prepare_for_bless",)) @@ -864,7 +875,10 @@ def collect_firmware(self, pkg): pass def install_files(self, cur_os): - pass + # As the real stub does, with the plain image older firmware ships. + image = Path(self.osi.recovery, self.osi.vgid, "usr/standalone/firmware/arm64eBaseSystem.dmg") + image.parent.mkdir(parents=True, exist_ok=True) + image.write_bytes(b"plain recovery image") with patch("omarchy_asahi.stub.StubInstaller", Stub), patch.dict( os.environ, {"OMARCHY_MACHINE_OWNER": owner, "OMARCHY_INSTALLER_NAME": self.title} @@ -1149,9 +1163,17 @@ def __init__(self, *args, vgid=vgid): recovery=root.name) self.step2_sh = str(step2) - def install_files(self, cur_os): + def load_identity(self): + pass + + def collect_firmware(self, pkg): pass + def install_files(self, cur_os): + image = Path(root.name, vgid, "usr/standalone/firmware/arm64eBaseSystem.dmg") + image.parent.mkdir(parents=True, exist_ok=True) + image.write_bytes(b"plain recovery image") + 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"): @@ -1233,5 +1255,224 @@ class Step2ScriptDashTests(Step2ScriptTests): # Linux's /bin/sh is often dash, so the fakes run under it too. fake_shell = shutil.which("dash") or "dash" +class Macos26BootcachesTests(unittest.TestCase): + class Stub: + def __init__(self, *args, bless2): + self.args = args + self.bless2 = bless2 + + def load_identity(self): + self.bootcaches = {"bless2": dict(self.bless2)} + return "identity" + + def collect_firmware(self, pkg): + return pkg + + def install_files(self, cur_os): + pass + + def make(self, bless2): + with patch( + "omarchy_asahi.stub.StubInstaller", + lambda *args: self.Stub(*args, bless2=bless2), + ): + return stub_installer("sysinfo", "dutil", "osinfo") + + def test_macos_26_bootcaches_get_the_restore_bundle_every_macos_uses(self): + # macOS 26.6 (25G72) bless2 names no RestoreBundlePath. + installer = self.make({"Version": 1, "SupportsExternalPrebootObjects": True}) + self.assertEqual(installer.load_identity(), "identity") + self.assertEqual(installer.bootcaches["bless2"]["RestoreBundlePath"], "./Restore") + self.assertEqual(installer.args, ("sysinfo", "dutil", "osinfo")) + + def test_a_named_restore_bundle_is_kept(self): + installer = self.make({"RestoreBundlePath": "./Elsewhere"}) + installer.load_identity() + self.assertEqual(installer.bootcaches["bless2"]["RestoreBundlePath"], "./Elsewhere") + + +class EncryptedRecoveryFirmwareTests(unittest.TestCase): + """macOS 26 recovery images are AEA archives hdiutil cannot attach.""" + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + root = Path(self.temporary.name) + self.work = root / "work" + self.work.mkdir() + self.host = root / "host" + (self.host / "usr/share/firmware/wifi").mkdir(parents=True) + (self.host / "usr/sbin").mkdir(parents=True) + (self.host / "usr/sbin/appleh13camerad").write_bytes(b"camera") + self.recovery = root / "stub-recovery" + self.image = self.recovery / "vgid-1/usr/standalone/firmware/arm64eBaseSystem.dmg" + self.image.parent.mkdir(parents=True) + previous = os.getcwd() + os.chdir(self.work) + self.addCleanup(os.chdir, previous) + self.runs = [] + self.seen = {} + + def make(self, image_bytes, host_build="25G72", ipsw_build="25G72"): + self.image.write_bytes(image_bytes) + test = self + + class Stub: + def __init__(self, sysinfo, dutil, osinfo): + self.sysinfo = SimpleNamespace(macos_build=host_build) + self.osi = SimpleNamespace(recovery=str(test.recovery), vgid="vgid-1") + self.manifest = {"ProductBuildVersion": ipsw_build} + + def load_identity(self): + self.bootcaches = {"bless2": {}} + + def collect_firmware(self, pkg): + fake_stub.subprocess.run(["hdiutil", "attach", "-quiet", "-readonly", + "-mountpoint", "recovery", str(test.image)], check=True) + if os.path.lexists("recovery"): + test.seen["wifi"] = os.path.isdir("recovery/usr/share/firmware/wifi") + test.seen["camera"] = Path("recovery/usr/sbin/appleh13camerad").read_bytes() + fake_stub.subprocess.run(["tar", "czf", "all_firmware.tar.gz"], check=True) + fake_stub.subprocess.run(["hdiutil", "detach", "-quiet", "recovery"]) + return pkg + + def install_files(self, cur_os): + pass + + def run(command, **kwargs): + test.runs.append(command) + return subprocess.CompletedProcess(command, 0) + + fake_stub = SimpleNamespace( + StubInstaller=Stub, + subprocess=SimpleNamespace(run=run, CompletedProcess=subprocess.CompletedProcess), + ) + self.fake_stub = fake_stub + with patch("omarchy_asahi.stub", fake_stub): + installer = stub_installer("sysinfo", "dutil", "osinfo") + return installer + + def collect(self, installer): + with patch("omarchy_asahi.stub", self.fake_stub), patch( + "omarchy_asahi.HOST_ROOT", str(self.host) + ): + return installer.collect_firmware("pkg") + + def test_firmware_comes_from_the_running_macos_of_the_same_build(self): + installer = self.make(b"AEA1" + b"\0" * 60) + self.assertEqual(self.collect(installer), "pkg") + self.assertEqual(self.seen, {"wifi": True, "camera": b"camera"}) + # Neither hdiutil call reaches the system; the tar still does. + self.assertEqual(self.runs, [["tar", "czf", "all_firmware.tar.gz"]]) + self.assertFalse(os.path.lexists("recovery")) + + def test_a_different_running_build_is_refused(self): + installer = self.make(b"AEA1" + b"\0" * 60, host_build="25G83") + with self.assertRaisesRegex(AsahiAdapterError, "25G72.*25G83"): + self.collect(installer) + self.assertEqual(self.runs, []) + + def test_a_plain_recovery_image_is_attached_as_before(self): + installer = self.make(b"koly" + b"\0" * 60) + self.collect(installer) + self.assertEqual(self.runs[0][:2], ["hdiutil", "attach"]) + self.assertEqual(self.runs[-1][:2], ["hdiutil", "detach"]) + + +STUB_VG = "5A6B7C8D-9E0F-4A1B-8C2D-3E4F5A6B7C8D" + + +class DecryptedMacos26ImagesTests(unittest.TestCase): + """The stub's recoveryOS cannot boot the AEA images in a macOS 26 restore image.""" + + AEA = b"AEA1" + b"\0" * 60 + UDIF = b"plain image" + b"\0" * 600 + b"koly" + b"\0" * 508 + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + root = Path(self.temporary.name) + self.stub_recovery = root / "stub-recovery" + self.stub_preboot = root / "stub-preboot" + self.host_recovery = root / "host-recovery" + self.host_preboot = root / "host-preboot" + self.stub_image = self.stub_recovery / STUB_VG / "usr/standalone/firmware/arm64eBaseSystem.dmg" + self.host_image = self.host_recovery / "host-vg/usr/standalone/firmware/arm64eBaseSystem.dmg" + self.stub_restore = self.stub_preboot / STUB_VG / "Restore" + self.host_restore = self.host_preboot / "host-vg/restore" + for path in (self.stub_image, self.host_image): + path.parent.mkdir(parents=True) + self.stub_restore.mkdir(parents=True) + self.host_restore.mkdir(parents=True) + self.host_image.write_bytes(self.UDIF) + (self.host_restore / "094-96734-085.dmg.aea").write_bytes(b"exclave plain") + self.step2 = root / "step2.sh" + self.cur_os = SimpleNamespace( + recovery=str(self.host_recovery), preboot=str(self.host_preboot), vgid="host-vg" + ) + + def make(self, image, restore, host_build="25G72", ipsw_build="25G72"): + self.stub_image.write_bytes(image) + for name, data in restore.items(): + (self.stub_restore / name).write_bytes(data) + test = self + + class Stub: + def __init__(self, *args): + self.sysinfo = SimpleNamespace(macos_build=host_build) + self.osi = SimpleNamespace( + recovery=str(test.stub_recovery), preboot=str(test.stub_preboot), vgid=STUB_VG, + preboot_vgid=STUB_VG, + ) + self.step2_sh = str(test.step2) + self.manifest = {"ProductBuildVersion": ipsw_build} + self.pb_vgid = str(test.stub_preboot / STUB_VG) + self.bootcaches = {"bless2": {}} + + def load_identity(self): + pass + + def collect_firmware(self, pkg): + pass + + def install_files(self, cur_os): + test.installed = cur_os + + with patch("omarchy_asahi.stub.StubInstaller", Stub): + return stub_installer("sysinfo", "dutil", "osinfo") + + def test_encrypted_images_are_replaced_by_the_running_macos_copies(self): + installer = self.make(self.AEA, {"094-96734-085.dmg.aea": self.AEA, "BuildManifest.plist": b"x"}) + installer.install_files(self.cur_os) + self.assertIs(self.installed, self.cur_os) + self.assertEqual(self.stub_image.read_bytes(), self.UDIF) + self.assertEqual((self.stub_restore / "094-96734-085.dmg.aea").read_bytes(), b"exclave plain") + self.assertEqual((self.stub_restore / "BuildManifest.plist").read_bytes(), b"x") + # The same install also writes Omarchy's Recovery setup. + self.assertIn(f'VGID="{STUB_VG}"', self.step2.read_text()) + + def test_older_firmware_is_left_alone(self): + installer = self.make(b"koly-free plain", {"094-1.dmg": b"plain"}) + installer.install_files(self.cur_os) + self.assertEqual(self.stub_image.read_bytes(), b"koly-free plain") + + def test_a_different_running_build_is_refused(self): + installer = self.make(self.AEA, {}, host_build="25G83") + with self.assertRaisesRegex(AsahiAdapterError, "25G72.*25G83"): + installer.install_files(self.cur_os) + self.assertEqual(self.stub_image.read_bytes(), self.AEA) + + def test_an_encrypted_file_without_a_decrypted_copy_is_refused(self): + installer = self.make(self.AEA, {"094-00000-000.dmg.aea": self.AEA}) + with self.assertRaisesRegex(AsahiAdapterError, "094-00000-000.dmg.aea"): + installer.install_files(self.cur_os) + + def test_an_encrypted_running_recovery_image_is_refused(self): + self.host_image.write_bytes(self.AEA) + installer = self.make(self.AEA, {}) + with self.assertRaisesRegex(AsahiAdapterError, "recovery image"): + installer.install_files(self.cur_os) + + if __name__ == "__main__": unittest.main() diff --git a/Engine/patches/0001-omarchy-engine-runtime.patch b/Engine/patches/0001-omarchy-engine-runtime.patch index 460c226..27c7d7a 100644 --- a/Engine/patches/0001-omarchy-engine-runtime.patch +++ b/Engine/patches/0001-omarchy-engine-runtime.patch @@ -35,10 +35,10 @@ index 21031fd..76c0935 100755 echo "Packaging installer..." diff --git a/src/main.py b/src/main.py -index e6407ba..0ecebb5 100644 +index 88dd2ab..85487b6 100644 --- a/src/main.py +++ b/src/main.py -@@ -4,6 +4,7 @@ import os, os.path, shlex, subprocess, sys, time, termios, json, getpass, report +@@ -4,11 +4,15 @@ import os, os.path, shlex, subprocess, sys, time, termios, json, getpass, report from dataclasses import dataclass import system, osenum, stub, diskutil, osinstall, asahi_firmware, m1n1, bugs @@ -46,7 +46,41 @@ index e6407ba..0ecebb5 100644 from util import * PART_ALIGN = psize("1MiB") -@@ -128,7 +129,10 @@ IPSW_VERSIONS = [ + + STUB_SIZE = align_down(psize("2.5GB"), PART_ALIGN) ++# A stub on macOS 26 firmware holds its decrypted recoveryOS and two sets of ++# personalized boot objects, which do not fit in 2.5GB. ++STUB_SIZE_MACOS26 = align_down(psize("6GB"), PART_ALIGN) + + # Minimum free space to leave when resizing, to allow for OS upgrades + MIN_FREE_OS = psize("38GB") +@@ -49,9 +53,11 @@ CHIP_MIN_VER = { + 0x6030: "14.8.3", # T6030, M3 Pro + 0x6031: "14.8.3", # T6031, M3 Max (16-core) + 0x6034: "14.8.3", # T6034, M3 Max (14-core) ++ 0x8140: "26.6", # T8140, A18 Pro + } + + DEVICES = { ++ "j700ap": Device("26.6", False), # MacBook Neo (A18 Pro, 2026) + "j274ap": Device("11.0", False), # Mac mini (M1, 2020) + "j293ap": Device("11.0", False), # MacBook Pro (13-inch, M1, 2020) + "j313ap": Device("11.0", False), # MacBook Air (M1, 2020) +@@ -95,6 +101,13 @@ if os.environ.get("ALLOW_VM", None): + DEVICES["vma2macosap"] = Device("12.0", False) + + IPSW_VERSIONS = [ ++ IPSW("26.6", ++ "26.0", ++ "mBoot-18000.161.9", ++ "25.7.72.0.0,0", ++ False, ++ ["j700ap"], ++ "https://updates.cdn-apple.com/2026SummerFCS/fullrestores/140-65618/10445B26-DE2C-43EC-9149-0A831602E74B/UniversalMac_26.6_25G72_Restore.ipsw"), + IPSW("12.3.1", + "12.1", + "iBoot-7459.101.3", +@@ -128,7 +141,10 @@ IPSW_VERSIONS = [ class InstallerMain: def __init__(self, version): self.version = version @@ -57,7 +91,7 @@ index e6407ba..0ecebb5 100644 self.credentials_validated = False self.expert = False self.ipsw = None -@@ -812,18 +816,15 @@ class InstallerMain: +@@ -812,18 +828,15 @@ class InstallerMain: else: target = resizable[0] @@ -85,7 +119,7 @@ index e6407ba..0ecebb5 100644 min_perc = 100 * min_size / total -@@ -922,6 +923,25 @@ class InstallerMain: +@@ -922,6 +935,25 @@ class InstallerMain: return True @@ -111,7 +145,7 @@ index e6407ba..0ecebb5 100644 def action_select_disk(self): choices = {"1": "Internal storage"} -@@ -939,21 +959,22 @@ class InstallerMain: +@@ -939,21 +971,22 @@ class InstallerMain: return True def main(self): @@ -147,7 +181,7 @@ index e6407ba..0ecebb5 100644 p_message("By default, this installer will hide certain advanced options that") p_message("are only useful for Asahi Linux developers. You can enable expert mode") p_message("to show them. Do not enable this unless you know what you are doing.") -@@ -970,7 +991,19 @@ class InstallerMain: +@@ -970,7 +1003,19 @@ class InstallerMain: self.chip_min_ver = CHIP_MIN_VER.get(self.sysinfo.chip_id, None) self.device = DEVICES.get(self.sysinfo.device_class, None) @@ -168,7 +202,7 @@ index e6407ba..0ecebb5 100644 p_error("This device is not supported yet!") p_error("Please check out the Asahi Linux Blog for updates on device support:") print() -@@ -1126,6 +1159,15 @@ class InstallerMain: +@@ -1126,6 +1171,17 @@ class InstallerMain: if self.cur_os is None and self.sysinfo.boot_mode != "macOS": self.cur_os = default_os @@ -177,14 +211,16 @@ index e6407ba..0ecebb5 100644 + installer=self, + free_parts=parts_free if is_gpt else [], + resizable_parts=parts_resizable if is_gpt else [], -+ stub_size=STUB_SIZE, ++ stub_size=(STUB_SIZE_MACOS26 ++ if split_ver(CHIP_MIN_VER.get(self.sysinfo.chip_id, "0")) >= split_ver("26.0") ++ else STUB_SIZE), + part_align=PART_ALIGN, + ) + return False self.check_cur_os() actions = {} -@@ -1226,7 +1268,11 @@ if __name__ == "__main__": +@@ -1226,7 +1282,11 @@ if __name__ == "__main__": logging.exception("Process execution failed") p_warning("If you need to file a bug report, please attach the log file:") p_warning(f" {os.getcwd()}/installer.log") diff --git a/Engine/patches/base/0001-omarchy-engine-runtime.patch b/Engine/patches/base/0001-omarchy-engine-runtime.patch new file mode 100644 index 0000000..460c226 --- /dev/null +++ b/Engine/patches/base/0001-omarchy-engine-runtime.patch @@ -0,0 +1,245 @@ +diff --git a/build.sh b/build.sh +index 21031fd..76c0935 100755 +--- a/build.sh ++++ b/build.sh +@@ -99,7 +99,7 @@ fi + echo "Copying files..." + + cp -r "$SRC"/* "$PACKAGE/" +-rm "$PACKAGE/asahi_firmware" ++rm -rf "$PACKAGE/asahi_firmware" + cp -r "$AFW" "$PACKAGE/" + if [ -r "$LOGO" ]; then + cp "$LOGO" "$PACKAGE/logo.icns" +@@ -125,10 +125,10 @@ mkdir -p "$PACKAGE/Frameworks/Python.framework" + # libarchive 3.7.2 (Ubuntu 24.04) is broken while 3.8.5 (Ubuntu 26.04) works + if $(bsdtar -tf "$DL/$PYTHON_PKG" Python_Framework.pkg/Payload > /dev/null); then + bsdtar -xOf "$DL/$PYTHON_PKG" Python_Framework.pkg/Payload | zcat | \ +- cpio -i -D "$PACKAGE/Frameworks/Python.framework" ++ (cd "$PACKAGE/Frameworks/Python.framework" && cpio -i) + else + 7z x -so "$DL/$PYTHON_PKG" Python_Framework.pkg/Payload | zcat | \ +- cpio -i -D "$PACKAGE/Frameworks/Python.framework" ++ (cd "$PACKAGE/Frameworks/Python.framework" && cpio -i) + fi + + cd "$PACKAGE/Frameworks/Python.framework/Versions/Current" +@@ -151,7 +151,8 @@ rm -f _test* _tkinter* + + echo "Copying certificates..." + +-certs="$(python3 -c 'import certifi; print(certifi.where())')" ++certs="$DL/certifi-cacert-2026.07.22.pem" ++[ -r "$certs" ] + cp "$certs" "$PACKAGE/Frameworks/Python.framework/Versions/Current/etc/openssl/cert.pem" + + echo "Packaging installer..." +diff --git a/src/main.py b/src/main.py +index e6407ba..0ecebb5 100644 +--- a/src/main.py ++++ b/src/main.py +@@ -4,6 +4,7 @@ import os, os.path, shlex, subprocess, sys, time, termios, json, getpass, report + from dataclasses import dataclass + + import system, osenum, stub, diskutil, osinstall, asahi_firmware, m1n1, bugs ++import omarchy_runtime + from util import * + + PART_ALIGN = psize("1MiB") +@@ -128,7 +129,10 @@ IPSW_VERSIONS = [ + class InstallerMain: + def __init__(self, version): + self.version = version ++ self.engine_runtime = omarchy_runtime.EngineRuntime.from_environment() + self.data = json.load(open("installer_data.json")) ++ if self.engine_runtime: ++ self.data = self.engine_runtime.metadata(self.data) + self.credentials_validated = False + self.expert = False + self.ipsw = None +@@ -812,18 +816,15 @@ class InstallerMain: + else: + target = resizable[0] + +- limits = self.dutil.get_resize_limits(target.name) +- +- total = target.container["CapacityCeiling"] +- free = target.container["CapacityFree"] +- min_free = self.get_min_free_space(target) +- # Minimum size, ignoring APFS snapshots & co, but with a conservative buffer +- min_size_raw = align_up(total - free + min_free, PART_ALIGN) +- # Minimum size reported by diskutil, considering APFS snapshots & co but with a less conservative buffer +- min_size_safe = limits["MinimumSizePreferred"] +- min_size = max(min_size_raw, min_size_safe) +- overhead = min_size - min_size_raw +- avail = total - min_size ++ bounds = self.get_resize_bounds(target) ++ total = bounds["total_bytes"] ++ free = bounds["free_bytes"] ++ min_free = bounds["reserved_free_bytes"] ++ min_size_raw = bounds["calculated_minimum_bytes"] ++ min_size_safe = bounds["diskutil_minimum_bytes"] ++ min_size = bounds["minimum_size_bytes"] ++ overhead = bounds["overhead_bytes"] ++ avail = bounds["available_bytes"] + + min_perc = 100 * min_size / total + +@@ -922,6 +923,25 @@ class InstallerMain: + + return True + ++ def get_resize_bounds(self, target): ++ limits = self.dutil.get_resize_limits(target.name) ++ total = target.container["CapacityCeiling"] ++ free = target.container["CapacityFree"] ++ min_free = self.get_min_free_space(target) ++ min_size_raw = align_up(total - free + min_free, PART_ALIGN) ++ min_size_safe = limits["MinimumSizePreferred"] ++ min_size = max(min_size_raw, min_size_safe) ++ return { ++ "total_bytes": total, ++ "free_bytes": free, ++ "reserved_free_bytes": min_free, ++ "calculated_minimum_bytes": min_size_raw, ++ "diskutil_minimum_bytes": min_size_safe, ++ "minimum_size_bytes": min_size, ++ "overhead_bytes": min_size - min_size_raw, ++ "available_bytes": total - min_size, ++ } ++ + def action_select_disk(self): + choices = {"1": "Internal storage"} + +@@ -939,21 +959,22 @@ class InstallerMain: + return True + + def main(self): +- print() +- p_message(f"Welcome to the {DISTRO} installer!") +- print() +- p_message("This installer will guide you through the process of setting up") +- p_message(f"{DISTRO} on your Mac.") +- print() +- p_message("Please make sure you are familiar with our documentation at:") +- p_plain( f" {col(BLUE, BRIGHT)}{DISTRO_DOCS}{col()}") +- print() +- p_question("Press enter to continue.") +- self.input() +- print() ++ if not self.engine_runtime: ++ print() ++ p_message(f"Welcome to the {DISTRO} installer!") ++ print() ++ p_message("This installer will guide you through the process of setting up") ++ p_message(f"{DISTRO} on your Mac.") ++ print() ++ p_message("Please make sure you are familiar with our documentation at:") ++ p_plain( f" {col(BLUE, BRIGHT)}{DISTRO_DOCS}{col()}") ++ print() ++ p_question("Press enter to continue.") ++ self.input() ++ print() + + self.expert = False +- if os.environ.get("EXPERT", None): ++ if os.environ.get("EXPERT", None) and not self.engine_runtime: + p_message("By default, this installer will hide certain advanced options that") + p_message("are only useful for Asahi Linux developers. You can enable expert mode") + p_message("to show them. Do not enable this unless you know what you are doing.") +@@ -970,7 +991,19 @@ class InstallerMain: + + self.chip_min_ver = CHIP_MIN_VER.get(self.sysinfo.chip_id, None) + self.device = DEVICES.get(self.sysinfo.device_class, None) +- if not self.chip_min_ver or not self.device or (self.device.expert_only and not self.expert): ++ supported = bool( ++ self.chip_min_ver ++ and self.device ++ and (not self.device.expert_only or self.expert) ++ ) ++ if self.engine_runtime: ++ self.engine_runtime.inspect( ++ self.sysinfo.device_class, ++ supported, ++ ) ++ if not supported: ++ return ++ if not supported: + p_error("This device is not supported yet!") + p_error("Please check out the Asahi Linux Blog for updates on device support:") + print() +@@ -1126,6 +1159,15 @@ class InstallerMain: + + if self.cur_os is None and self.sysinfo.boot_mode != "macOS": + self.cur_os = default_os ++ if self.engine_runtime: ++ self.engine_runtime.run_layout( ++ installer=self, ++ free_parts=parts_free if is_gpt else [], ++ resizable_parts=parts_resizable if is_gpt else [], ++ stub_size=STUB_SIZE, ++ part_align=PART_ALIGN, ++ ) ++ return False + self.check_cur_os() + + actions = {} +@@ -1226,7 +1268,11 @@ if __name__ == "__main__": + logging.exception("Process execution failed") + p_warning("If you need to file a bug report, please attach the log file:") + p_warning(f" {os.getcwd()}/installer.log") ++ if os.environ.get("OMARCHY_ENGINE_MODE"): ++ raise + except Exception: + logging.exception("Exception caught") + p_warning("If you need to file a bug report, please attach the log file:") + p_warning(f" {os.getcwd()}/installer.log") ++ if os.environ.get("OMARCHY_ENGINE_MODE"): ++ raise +diff --git a/src/osenum.py b/src/osenum.py +index d99a1e1..76f9a94 100644 +--- a/src/osenum.py ++++ b/src/osenum.py +@@ -226,6 +226,11 @@ class OSEnum: + except FileNotFoundError: + logging.info(f" Not Found") + continue ++ except PermissionError: ++ # A stub created under a private umask is readable only by ++ # root; an unprivileged enumeration still has to list it. ++ logging.warning(f" Not readable, version unknown") ++ break + try: + osi.sys_vol_bootable = fsctl_is_bootable(mounts["System"]) + except Exception as e: +diff --git a/src/osinstall.py b/src/osinstall.py +--- a/src/osinstall.py ++++ b/src/osinstall.py +@@ -127,6 +127,13 @@ + fd.write(data) + ucache.flush_progress() + ++ def install_raw_image(self, image, info): ++ zinfo = self.pkg.getinfo(image) ++ if zinfo.file_size % (4 * 1024) != 0: ++ raise Exception("The size of the rootfs image file must be a multiple of 4KiB.") ++ with self.pkg.open(image) as source, open(f"/dev/r{info.name}", "r+b") as target: ++ self.fdcopy(source, target, zinfo.file_size) ++ + def install(self, stub_ins): + p_progress("Installing OS...") + logging.info("OSInstaller.install()") +@@ -146,12 +153,7 @@ + if image: + p_plain(f" Extracting {image} into {info.name} partition...") + logging.info(f"Extract: {image}") +- zinfo = self.pkg.getinfo(image) +- if zinfo.file_size % (4 * 1024) != 0: +- raise Exception("The size of the rootfs image file must be a multiple of 4KiB.") +- with self.pkg.open(image) as sfd, \ +- open(f"/dev/r{info.name}", "r+b") as dfd: +- self.fdcopy(sfd, dfd, zinfo.file_size) ++ self.install_raw_image(image, info) + self.flush_progress() + source = part.get("source", None) + if source: diff --git a/Engine/rebuild-python-overlay.py b/Engine/rebuild-python-overlay.py index 3046760..bbaed91 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.28' +VERSION = 'v0.9.2-omarchy.29' _SPEC = importlib.util.spec_from_file_location( 'verify_source_lock', Path(__file__).resolve().parent / 'verify-source-lock.py') @@ -50,9 +50,9 @@ def apply_downstream_patch(patch, path, content): return target.read_bytes() -def upstream_delta(checkout, delta, archive, patch): +def upstream_delta(checkout, delta, archive, patch, base_patch): # The base keeps its native runtime and m1n1, so upstream may only have changed the listed Python files. - VERIFY.require_upstream_delta(delta, patch.read_text()) + VERIFY.require_upstream_delta(delta, patch.read_text(), base_patch.read_text()) changed = git(checkout, 'diff', '--name-only', delta['base_commit'], 'HEAD').decode().splitlines() if sorted(changed) != sorted(item['path'] for item in delta['files']): raise ValueError('upstream changes since the base differ from the source lock') @@ -64,7 +64,9 @@ def upstream_delta(checkout, delta, archive, patch): if sha256(base) != item['upstream_base_sha256'] or sha256(new) != item['upstream_sha256']: raise ValueError('upstream delta digest mismatch: ' + path) if item['downstream_patched']: - base = apply_downstream_patch(patch, path, base) + # The base was built with the patch of its day; rebuilding it with that exact patch must + # still reproduce what it shipped, while the new file takes the current patch. + base = apply_downstream_patch(base_patch, path, base) new = apply_downstream_patch(patch, path, new) if sha256(base) != item['base_sha256'] or sha256(new) != item['sha256']: raise ValueError('patched upstream delta digest mismatch: ' + path) @@ -99,7 +101,8 @@ def rebuild(checkout, base, output): actual = {str(p.relative_to(root)) for p in (root / 'overlay/src').glob('*.py')} if expected != actual: raise ValueError('Python overlay inventory differs from source lock') - for item in records + lock['build_recipe'] + [lock['downstream_overlay']['patch']]: + base_patch = lock['incremental_build']['base_patch'] + for item in records + lock['build_recipe'] + [lock['downstream_overlay']['patch'], base_patch]: if sha256((root / item['path']).read_bytes()) != item['sha256']: raise ValueError('source lock digest mismatch: ' + item['path']) overlay = {Path(name).name: (root / name).read_bytes() for name in sorted(expected)} @@ -109,7 +112,8 @@ def rebuild(checkout, base, output): if sha256(archive.extractfile('./installer_data.json').read()) != lock['validation_artifact']['metadata_sha256']: raise ValueError('base engine metadata differs from the source lock') delta = upstream_delta(checkout, lock['incremental_build']['upstream_delta'], archive, - root / lock['downstream_overlay']['patch']['path']) + root / lock['downstream_overlay']['patch']['path'], + root / base_patch['path']) # The hook below rewrites the base archive's osinstall.py, so an upstream osinstall.py change would be lost. if delta.keys() & (overlay.keys() | {'osinstall.py'}): raise ValueError('upstream delta overlaps the downstream overlay or osinstall hook') diff --git a/Engine/source-lock.json b/Engine/source-lock.json index a3b34a8..9e449e6 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -141,7 +141,7 @@ } }, "downstream_overlay": { - "version": "v0.9.2-omarchy.28", + "version": "v0.9.2-omarchy.29", "capability": "candidate_bound_full_os_stage_one_authenticated_recovery", "engine_modes": [ "inspect", @@ -150,7 +150,7 @@ ], "patch": { "path": "patches/0001-omarchy-engine-runtime.patch", - "sha256": "c6f625c62250993fe8dd11f210225d2667b6ab341fa8922209ce0b435a3b60a7" + "sha256": "d6195ae8d436f9e4309d131e673dd609c74da584b5d0a96e7006927f8d915f56" }, "files": [ { @@ -171,7 +171,7 @@ { "path": "overlay/src/omarchy_asahi.py", "destination": "src/omarchy_asahi.py", - "sha256": "a5d887b1d07a1977ff397980d247c9138a61cfffb675ce51aa18d07589030f0c" + "sha256": "40e90130c2a69ee01831a10d176fc08347ef21785c6361ba2d39cf66e5496618" }, { "path": "overlay/src/omarchy_contract.py", @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "bf57c80bf26bbfc00a3cb24d1a5f8fc1196e8c155789fd4af97dddc0e4b7292a" + "sha256": "f02e8ceff546dc5749ca19392705b45eb14c1b8ffd9b2ff0bc37ea53b95f558d" }, { "path": "overlay/tests/test_omarchy_contract.py", @@ -266,11 +266,11 @@ }, { "path": "verify-source-lock.py", - "sha256": "21c230c7120388e0691ccd34ad621073c7c677b3f3d55028ec78249f4805597f" + "sha256": "ee2cd52a254696ca86594db4d843b2580d22286d55ce189ec9f34b8b21019302" }, { "path": "rebuild-python-overlay.py", - "sha256": "3cc29ff5c7bd834b40a50ef2271417efcd33bc7bc747b11c743d5ba2042017c2" + "sha256": "b56c1b76976aeaa4a9beee1db810a155bb270fbf62522d43eea7a195a6212dc4" } ], "validation_artifact": { @@ -303,9 +303,13 @@ "upstream_base_sha256": "c8d4d2a80f3a88a9ba4d6d4024c0001062e35d15d72ae530681a0612e6525984", "upstream_sha256": "894fb8cd2ffc672bb96a14969ee9de2879d250c8c5990fee122ccf66d9b50024", "base_sha256": "95760ed02d44b5457acb81559a4e2d892ffbb7f0f2b7417c0b2eb02937945ae7", - "sha256": "d7fb4fcc9bdc13cba5a9f9c9ecace6317c60d816915950d9dfa05f826737afbd" + "sha256": "1208e9105815aa2d639190d8d26fc6804fadea01e776cce6e499ef2676a91b4e" } ] + }, + "base_patch": { + "path": "patches/base/0001-omarchy-engine-runtime.patch", + "sha256": "c6f625c62250993fe8dd11f210225d2667b6ab341fa8922209ce0b435a3b60a7" } } } diff --git a/Engine/tests/test_rebuild_python_overlay.py b/Engine/tests/test_rebuild_python_overlay.py index b5100f3..91bffcc 100644 --- a/Engine/tests/test_rebuild_python_overlay.py +++ b/Engine/tests/test_rebuild_python_overlay.py @@ -86,6 +86,8 @@ def setUp(self): self.checkout.mkdir() self.patch = root / "0001-omarchy-engine-runtime.patch" self.patch.write_text(PATCH) + self.base_patch = root / "base-0001-omarchy-engine-runtime.patch" + self.base_patch.write_text(PATCH) self.git("init", "-q") self.write("asahi_firmware/bluetooth.py", b"old\n") self.write("src/main.py", MAIN_BASE) @@ -114,7 +116,9 @@ def delta(self, *records): return {"base_commit": self.base_commit, "files": list(records or [BLUETOOTH])} def rebuild(self, delta, archive_files): - return REBUILD.upstream_delta(self.checkout, delta, archive_with(archive_files), self.patch) + return REBUILD.upstream_delta( + self.checkout, delta, archive_with(archive_files), self.patch, self.base_patch + ) def test_exact_python_delta_is_overlaid(self): self.commit() @@ -162,6 +166,30 @@ def test_base_engine_without_the_downstream_patch_is_rejected(self): {"asahi_firmware/bluetooth.py": b"old\n", "main.py": MAIN_BASE}, ) + def test_base_keeps_the_patch_it_was_built_with(self): + # The current patch has moved on; the base is still rebuilt with its own patch and must + # match what the base engine shipped. + self.patch.write_text(PATCH.replace("+import omarchy_runtime", "+import omarchy_neo")) + self.write("src/main.py", MAIN_NEW) + self.commit() + current = MAIN_NEW.replace(b"import os\n", b"import os\nimport omarchy_neo\n") + overlay = self.rebuild( + self.delta(BLUETOOTH, {**MAIN, "sha256": digest(current)}), + {"asahi_firmware/bluetooth.py": b"old\n", "main.py": MAIN_BASE_PATCHED}, + ) + self.assertEqual(overlay["main.py"], current) + + def test_base_rebuilt_with_the_current_patch_is_rejected(self): + self.patch.write_text(PATCH.replace("+import omarchy_runtime", "+import omarchy_neo")) + self.base_patch.write_text(PATCH.replace("+import omarchy_runtime", "+import omarchy_neo")) + self.write("src/main.py", MAIN_NEW) + self.commit() + with self.assertRaisesRegex(ValueError, "patched upstream delta digest mismatch"): + self.rebuild( + self.delta(BLUETOOTH, MAIN), + {"asahi_firmware/bluetooth.py": b"old\n", "main.py": MAIN_BASE_PATCHED}, + ) + def test_patched_content_must_match_lock(self): self.write("src/main.py", MAIN_NEW) self.commit() diff --git a/Engine/tests/test_verify_source_lock.py b/Engine/tests/test_verify_source_lock.py index 1dd0338..5173c75 100644 --- a/Engine/tests/test_verify_source_lock.py +++ b/Engine/tests/test_verify_source_lock.py @@ -157,15 +157,26 @@ def test_patched_and_unpatched_files_are_accepted(self): delta_record("src/main.py", True), ), PATCH, + PATCH, ) def test_repository_lock_matches_the_downstream_patch(self): lock = json.loads((ENGINE_ROOT / "source-lock.json").read_text()) patch = ENGINE_ROOT / lock["downstream_overlay"]["patch"]["path"] + base_patch = ENGINE_ROOT / lock["incremental_build"]["base_patch"]["path"] VERIFY_SOURCE_LOCK.require_upstream_delta( - lock["incremental_build"]["upstream_delta"], patch.read_text() + lock["incremental_build"]["upstream_delta"], + patch.read_text(), + base_patch.read_text(), ) + def test_patched_file_missing_from_the_base_patch_is_rejected(self): + base_patch = PATCH.split("diff --git a/src/main.py")[0] + with self.assertRaisesRegex(ValueError, "base patch coverage"): + VERIFY_SOURCE_LOCK.require_upstream_delta( + delta(delta_record("src/main.py", True)), PATCH, base_patch + ) + def test_patch_paths_are_read_from_git_headers(self): self.assertEqual( VERIFY_SOURCE_LOCK.patched_paths(PATCH), {"build.sh", "src/main.py"} @@ -174,13 +185,13 @@ def test_patch_paths_are_read_from_git_headers(self): def test_patched_file_declared_unpatched_is_rejected(self): with self.assertRaisesRegex(ValueError, "patch coverage"): VERIFY_SOURCE_LOCK.require_upstream_delta( - delta(delta_record("src/main.py", False)), PATCH + delta(delta_record("src/main.py", False)), PATCH, PATCH ) def test_unpatched_file_declared_patched_is_rejected(self): with self.assertRaisesRegex(ValueError, "patch coverage"): VERIFY_SOURCE_LOCK.require_upstream_delta( - delta(delta_record("asahi_firmware/bluetooth.py", True)), PATCH + delta(delta_record("asahi_firmware/bluetooth.py", True)), PATCH, PATCH ) def test_unpatched_file_with_distinct_shipped_digest_is_rejected(self): @@ -192,19 +203,20 @@ def test_unpatched_file_with_distinct_shipped_digest_is_rejected(self): ) ), PATCH, + PATCH, ) def test_non_python_file_is_rejected(self): with self.assertRaisesRegex(ValueError, "Python only"): VERIFY_SOURCE_LOCK.require_upstream_delta( - delta(delta_record("build.sh", True)), PATCH + delta(delta_record("build.sh", True)), PATCH, PATCH ) def test_record_without_upstream_digests_is_rejected(self): record = delta_record("asahi_firmware/bluetooth.py", False) del record["upstream_sha256"] with self.assertRaisesRegex(ValueError, "file record"): - VERIFY_SOURCE_LOCK.require_upstream_delta(delta(record), PATCH) + VERIFY_SOURCE_LOCK.require_upstream_delta(delta(record), PATCH, PATCH) def test_duplicate_or_escaping_paths_are_rejected(self): record = delta_record("asahi_firmware/bluetooth.py", False) @@ -213,11 +225,11 @@ def test_duplicate_or_escaping_paths_are_rejected(self): [delta_record("../main.py", False)], ): with self.assertRaisesRegex(ValueError, "upstream delta path"): - VERIFY_SOURCE_LOCK.require_upstream_delta(delta(*records), PATCH) + VERIFY_SOURCE_LOCK.require_upstream_delta(delta(*records), PATCH, PATCH) def test_empty_delta_is_rejected(self): with self.assertRaisesRegex(ValueError, "upstream delta lock record"): - VERIFY_SOURCE_LOCK.require_upstream_delta(delta(), PATCH) + VERIFY_SOURCE_LOCK.require_upstream_delta(delta(), PATCH, PATCH) if __name__ == "__main__": diff --git a/Engine/verify-source-lock.py b/Engine/verify-source-lock.py index 5f5a24e..612d5fc 100755 --- a/Engine/verify-source-lock.py +++ b/Engine/verify-source-lock.py @@ -100,7 +100,7 @@ def patched_paths(patch: str) -> set[str]: return set(re.findall(r"^diff --git a/(\S+) b/\S+$", patch, re.MULTILINE)) -def require_upstream_delta(delta: dict, patch: str) -> None: +def require_upstream_delta(delta: dict, patch: str, base_patch: str) -> None: required = { "path", "downstream_patched", @@ -116,6 +116,7 @@ def require_upstream_delta(delta: dict, patch: str) -> None: if not isinstance(delta["files"], list) or not delta["files"]: raise ValueError("invalid upstream delta lock record") patched = patched_paths(patch) + base_patched = patched_paths(base_patch) seen = set() for record in delta["files"]: if ( @@ -144,6 +145,8 @@ def require_upstream_delta(delta: dict, patch: str) -> None: raise ValueError( "downstream patch coverage differs from source lock: " + path ) + if record["downstream_patched"] and path not in base_patched: + raise ValueError("base patch coverage differs from source lock: " + path) if not record["downstream_patched"] and ( record["base_sha256"] != record["upstream_base_sha256"] or record["sha256"] != record["upstream_sha256"] @@ -226,9 +229,12 @@ def verify(engine_root: Path, checkout: Path) -> None: raise ValueError("overlay destination is invalid") for item in lock["build_recipe"]: require_digest(engine_root, item, "build recipe") + base_patch = lock["incremental_build"]["base_patch"] + require_digest(engine_root, base_patch, "base patch") require_upstream_delta( lock["incremental_build"]["upstream_delta"], (engine_root / overlay["patch"]["path"]).read_text(encoding="utf-8"), + (engine_root / base_patch["path"]).read_text(encoding="utf-8"), ) From aaddf563455002d64c4afd97e5299259ac867228 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Thu, 1 Oct 2026 14:40:40 -0400 Subject: [PATCH 02/10] Add a developer build for Mac bring-up teams OMARCHY_DEVELOPER_BUILD=1 marks the app with OmarchyDeveloperBuild: its own workspace, no channel menu, and only a sealed catalog in the bundle, so a developer catalog that admits Macs the public ones do not can never reach it over the network. Name the MacBook Neo, and stop the bundling step from overwriting the inspection engine when the catalog's engine is the same file. Co-Authored-By: Claude Opus 5.5 --- Packaging/build-app.sh | 18 ++++++++++++++++-- .../InstallerBuildProfile.swift | 9 ++++++++- .../MacModelNames.swift | 1 + .../InstallerBuildProfileTests.swift | 19 +++++++++++++++++++ .../PlainLanguageTests.swift | 6 ++++++ 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/Packaging/build-app.sh b/Packaging/build-app.sh index 5ef6bd3..04be512 100755 --- a/Packaging/build-app.sh +++ b/Packaging/build-app.sh @@ -87,6 +87,9 @@ sealed_catalog_signature="$release_directory/catalog.json.sig" [[ ${OMARCHY_PRIVATE_PLAIN_TEST:-0} != "1" || ${OMARCHY_PRIVATE_LIMINE_TEST:-0} != "1" ]] \ || fail "private plain and Limine profiles are mutually exclusive" +[[ ${OMARCHY_DEVELOPER_BUILD:-0} != "1" \ + || ( ${OMARCHY_PRIVATE_PLAIN_TEST:-0} != "1" && ${OMARCHY_PRIVATE_LIMINE_TEST:-0} != "1" ) ]] \ + || fail "the developer build cannot also be a private profile" sealed_catalog_available=false if [[ -e $sealed_catalog || -L $sealed_catalog \ || -e $sealed_catalog_signature || -L $sealed_catalog_signature ]]; then @@ -109,6 +112,11 @@ fi if [[ ${OMARCHY_PRIVATE_LIMINE_TEST:-0} == "1" && $sealed_catalog_available != "true" ]]; then fail "private Limine builds require a sealed private catalog" fi +# A developer catalog can admit Macs no public catalog does, so it must never +# reach a developer build over the network. +if [[ ${OMARCHY_DEVELOPER_BUILD:-0} == "1" && $sealed_catalog_available != "true" ]]; then + fail "developer builds require a sealed developer catalog" +fi if [[ ${OMARCHY_PRIVATE_LIMINE_TEST:-0} == "1" ]]; then python3 "$script_directory/private-test/prepare-limine-assets.py" --verify-release "$release_directory" >/dev/null @@ -270,8 +278,11 @@ for model in json.loads(catalog.read_text())["models"]: if source.stat().st_size != artifact["sizeBytes"] or hashlib.sha256(source.read_bytes()).hexdigest() != expected: raise SystemExit("bundled engine differs from signed catalog") target = destination / name - if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() != expected: - raise SystemExit("bundled engine conflicts with inspection engine") + if target.exists(): + if hashlib.sha256(target.read_bytes()).hexdigest() != expected: + raise SystemExit("bundled engine conflicts with inspection engine") + # The inspection engine is already this exact execution engine. + continue shutil.copyfile(source, target) target.chmod(0o444) PYCODE @@ -294,6 +305,9 @@ fi if [[ ${OMARCHY_PRIVATE_LIMINE_TEST:-0} == "1" ]]; then plutil -insert OmarchyPrivateLimineTest -bool true "$contents/Info.plist" fi +if [[ ${OMARCHY_DEVELOPER_BUILD:-0} == "1" ]]; then + plutil -insert OmarchyDeveloperBuild -bool true "$contents/Info.plist" +fi plutil -replace CFBundleVersion \ -string "$build_number" "$contents/Info.plist" plutil -replace CFBundleIdentifier \ diff --git a/Sources/OmarchyInstallerUXCore/InstallerBuildProfile.swift b/Sources/OmarchyInstallerUXCore/InstallerBuildProfile.swift index 6a6733b..363fdb7 100644 --- a/Sources/OmarchyInstallerUXCore/InstallerBuildProfile.swift +++ b/Sources/OmarchyInstallerUXCore/InstallerBuildProfile.swift @@ -7,6 +7,9 @@ case standard case privatePlain case privateLimine + /// For bring-up teams: its sealed catalog may admit Macs the public + /// catalogs don't, so its state never mixes with theirs. + case developer public static var current: Self { resolve(infoDictionary: Bundle.main.infoDictionary ?? [:]) @@ -20,6 +23,9 @@ if infoDictionary["OmarchyPrivateLimineTest"] as? Bool == true { return .privateLimine } + if infoDictionary["OmarchyDeveloperBuild"] as? Bool == true { + return .developer + } return .standard } @@ -31,12 +37,13 @@ case .standard: InstallerProductIdentity.appIdentifier case .privatePlain: InstallerProductIdentity.appIdentifier + ".private-m3-20260922" case .privateLimine: InstallerProductIdentity.appIdentifier + ".private-limine-20260922" + case .developer: InstallerProductIdentity.appIdentifier + ".developer" } } public var startupSequence: String { switch self { - case .standard, .privateLimine: "m1n1 → U-Boot → Limine → Omarchy" + case .standard, .privateLimine, .developer: "m1n1 → U-Boot → Limine → Omarchy" case .privatePlain: "m1n1 → U-Boot → GRUB → Omarchy" } } diff --git a/Sources/OmarchyInstallerUXCore/MacModelNames.swift b/Sources/OmarchyInstallerUXCore/MacModelNames.swift index f1c826d..4fca0a9 100644 --- a/Sources/OmarchyInstallerUXCore/MacModelNames.swift +++ b/Sources/OmarchyInstallerUXCore/MacModelNames.swift @@ -87,6 +87,7 @@ "apple,j613": mac("MacBook Air 13-inch (M3, 2024)", "MacBook Air", "M3"), "apple,j615": mac("MacBook Air 15-inch (M3, 2024)", "MacBook Air", "M3"), "apple,j614s": mac("MacBook Pro 14-inch (M4 Pro, 2024)", "MacBook Pro", "M4"), + "apple,j700": mac("MacBook Neo (A18 Pro, 2026)", "MacBook Neo", "A18 Pro"), ] } #endif diff --git a/Tests/OmarchyInstallerUXCoreTests/InstallerBuildProfileTests.swift b/Tests/OmarchyInstallerUXCoreTests/InstallerBuildProfileTests.swift index 73e749e..b79cfcd 100644 --- a/Tests/OmarchyInstallerUXCoreTests/InstallerBuildProfileTests.swift +++ b/Tests/OmarchyInstallerUXCoreTests/InstallerBuildProfileTests.swift @@ -40,6 +40,25 @@ XCTAssertFalse(profile.startupSequence.contains("GRUB")) } + func testDeveloperBuildKeepsItsOwnStateAndHidesChannels() { + let profile = InstallerBuildProfile.resolve(infoDictionary: ["OmarchyDeveloperBuild": true]) + XCTAssertEqual(profile, .developer) + XCTAssertTrue(profile.allowsEncryption) + XCTAssertFalse(profile.showsReleaseChannels) + XCTAssertEqual(profile.workspaceName, InstallerProductIdentity.appIdentifier + ".developer") + for other in [InstallerBuildProfile.standard, .privatePlain, .privateLimine] { + XCTAssertNotEqual(profile.workspaceName, other.workspaceName) + } + XCTAssertEqual(profile.startupSequence, "m1n1 → U-Boot → Limine → Omarchy") + } + + func testPrivateFlagsOutrankTheDeveloperFlag() { + let profile = InstallerBuildProfile.resolve(infoDictionary: [ + "OmarchyDeveloperBuild": true, "OmarchyPrivatePlainTest": true, + ]) + XCTAssertEqual(profile, .privatePlain) + } + func testConflictingFlagsRetainPlainRestriction() { let profile = InstallerBuildProfile.resolve(infoDictionary: [ "OmarchyPrivatePlainTest": true, "OmarchyPrivateLimineTest": true, diff --git a/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift b/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift index 953bdc2..f6229ee 100644 --- a/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift +++ b/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift @@ -243,6 +243,12 @@ } } + func testTheMacBookNeoHasAName() { + XCTAssertEqual(MacModelNames.name(for: "apple,j700"), "MacBook Neo (A18 Pro, 2026)") + XCTAssertEqual( + MacModelNames.supportedFamiliesSummary(["apple,j700"]), "A18 Pro: MacBook Neo") + } + func testEveryPhaseHasADistinctTitle() { let phases = [ "preflight", "existing_removal", "apfs_preparation", "stub_and_esp", From 0046a50f618684f30a4907b6411b13c5ba7a0dcc Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Thu, 1 Oct 2026 14:40:40 -0400 Subject: [PATCH 03/10] Recognize a MacBook Neo install's 6 GB stub for removal Removal took a stub outside 2.3-2.7 GB for another macOS installation and refused it. A stub on macOS 26 firmware is 5,999,951,872 bytes. Co-Authored-By: Claude Opus 5.5 --- .../OmarchyRemovalRecognition.swift | 10 +++++++--- .../OmarchyRemovalFixtures.swift | 12 ++++++++++++ .../OmarchyRemovalTests.swift | 9 +++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift b/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift index 537ecdb..9177767 100644 --- a/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift +++ b/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift @@ -10,8 +10,11 @@ static let minimumFreeSpace: UInt64 = 1 << 30 static let memberGap: UInt64 = 16 << 20 - // asahi-installer's STUB_SIZE is 2,499,805,184 bytes. - static let stubSizes: ClosedRange = 2_300_000_000...2_700_000_000 + // asahi-installer's STUB_SIZE is 2,499,805,184 bytes; a stub on macOS 26 + // firmware (the MacBook Neo) is 5,999,951,872. + static let stubSizes: [ClosedRange] = [ + 2_300_000_000...2_700_000_000, 5_800_000_000...6_200_000_000, + ] static let maximumESP: UInt64 = 1 << 30 static func recognize(_ snapshot: RemovalSnapshot) throws -> RemovalLayout { @@ -85,7 +88,8 @@ let systems = volumes.filter { $0.roles == ["System"] } let data = volumes.filter { $0.roles == ["Data"] } guard !systems.isEmpty, !data.isEmpty else { return .foreign } - guard stubSizes.contains(part.size), volumes.count == 4, systems.count == 1, + guard stubSizes.contains(where: { $0.contains(part.size) }), volumes.count == 4, + systems.count == 1, data.count == 1, let group = systems[0].group, data[0].group == group, volumes.filter({ $0.roles == ["Preboot"] }).count == 1, volumes.filter({ $0.roles == ["Recovery"] }).count == 1 diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalFixtures.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalFixtures.swift index 6d92d8b..4cf8059 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalFixtures.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalFixtures.swift @@ -127,6 +127,18 @@ ], installs: [convergedInstall]) } + /// The MacBook Neo layout: macOS 26 firmware needs a 6 GB stub. + static func neoMacos26() -> RemovalSnapshot { + snapshot( + macOSSize: 194_332_676_096, + [ + .part(convergedInstall.stub, "Apple_APFS", 5_999_951_872), + .part(convergedInstall.esp, "EFI", 524_288_000, name: "EFI - OMARC"), + .part(convergedInstall.linux[0], "Linux Filesystem", 2_147_483_648), + .part(convergedInstall.linux[1], "Linux Filesystem", 256_798_965_760), + ], installs: [convergedInstall]) + } + static let alarmInstall = Install( name: "Asahi Alarm Minimal", stub: id(3), esp: id(4), linux: [id(5)], group: id(600)) diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift index 67cf790..acdcb06 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift @@ -19,6 +19,15 @@ XCTAssertEqual(plan.targetMacOSBytes, 994_662_584_320) } + func testANeoInstallWithItsMacos26StubIsRecognised() throws { + let disk = FakeRemovalDisk(F.neoMacos26(), install: F.convergedInstall) + let plan = try OmarchyRemovalPlan(disks: disk) + XCTAssertEqual(plan.kind, .installation) + XCTAssertEqual( + plan.members.map(\.uuid), + [F.convergedInstall.stub, F.convergedInstall.esp] + F.convergedInstall.linux) + } + func testOlderOmarchyMacInstallIsRecognisedWithItsOneRootPartition() throws { let disk = FakeRemovalDisk(F.alarm(), install: F.alarmInstall) let plan = try OmarchyRemovalPlan(disks: disk) From 91b572cab7f0c225b03cb1826a33abb2979cd721 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Thu, 1 Oct 2026 14:40:41 -0400 Subject: [PATCH 04/10] Let developer builds admit unqualified boards their engine supports Public builds keep unqualified boards such as apple,j614s fail-closed. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f6187c7..277f06f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,8 +50,10 @@ Local source edits, pure tests, read-only inspection, and disposable artifacts are reversible. Obtain the owner's explicit authorization immediately before helper registration, privileged execution, disk or boot-policy mutation, signing or notarization with production credentials, publication, deployment, -or physical-device work. Keep `apple,j614s` fail-closed until official support -and physical qualification both exist. +or physical-device work. Public builds keep unqualified boards such as +`apple,j614s` fail-closed until official support and physical qualification +both exist. Developer builds (`OmarchyDeveloperBuild`, sealed developer +catalog only) may admit unqualified boards their engine supports. ## Standalone repository conventions From 84af01da32ec2e21bc7b98d1ae33fbcacf568d54 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Thu, 1 Oct 2026 23:56:11 -0400 Subject: [PATCH 05/10] Boot a MacBook Neo through its own m1n1 iBoot panics on stock asahi m1n1 as the J700's custom boot object. On a j700ap the engine now replaces asahi's Stage 1 with Aurora's J700 Stage 1 from the image's esp/aurora/stage1-j700.bin, filled in place (a port of aurora-silicon/m1n1 tools/fill_stage1_config.py) to chainload the new ESP's m1n1/boot.bin. A Neo image without that file is refused; other Macs keep asahi's Stage 1. Firmware collection also counts C1FE multitouch keys as trackpads: the J700_Multitouch.im4p keys its trackpad C1FE0,0, which asahi_firmware skipped, leaving no apple/tpmtfw-j700.bin and a dead trackpad. Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/src/omarchy_asahi.py | 95 +++++++++++++++++- Engine/overlay/tests/test_omarchy_asahi.py | 109 ++++++++++++++++++++- Engine/source-lock.json | 4 +- 3 files changed, 202 insertions(+), 6 deletions(-) diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index 681af4a..497f0be 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: MIT """Concrete stage-1 adapter over pinned upstream Asahi primitives.""" +import contextlib import hashlib import io import json @@ -8,9 +9,11 @@ import re import stat import shutil +import struct import subprocess import sys import zipfile +import zlib from pathlib import PurePosixPath import asahi_firmware @@ -379,6 +382,57 @@ def _write_step2(installer): with open(installer.step2_sh, "w") as fd: fd.write(script) os.chmod(installer.step2_sh, 0o755) +# Aurora's J700 Stage 1 carries one versioned config block that names the ESP +# and the Stage 2 path; it is filled in place so the image keeps its length +# and STACKBOT tail (port of aurora-silicon/m1n1 tools/fill_stage1_config.py, +# MIT). +J700_STAGE1 = "esp/aurora/stage1-j700.bin" +J700_STAGE1_MAGIC = b"AURORA-S1-CFG01\0" +J700_STAGE1_BODY = struct.Struct("= 192: + raise AsahiAdapterError("Stage 2 path is too long") + if not 0 <= window_ms <= 99999: + raise AsahiAdapterError("proxy window must be 0..99999 ms") + if not image.endswith(b"STACKBOT"): + raise AsahiAdapterError("J700 Stage 1 does not end at STACKBOT") + if image.count(J700_STAGE1_MAGIC) != 1: + raise AsahiAdapterError("J700 Stage 1 config block must occur exactly once") + offset = image.index(J700_STAGE1_MAGIC) + if offset + J700_STAGE1_BLOCK > len(image) - 8: + raise AsahiAdapterError("J700 Stage 1 config block extends past the image") + if struct.unpack_from(" Date: Sun, 4 Oct 2026 20:18:55 -0400 Subject: [PATCH 06/10] Collect a MacBook Neo's radio firmware from macOS On a j700ap the engine now adds the MT7932's Wi-Fi and Bluetooth inputs to the vendor firmware package: the IZUBA patch, RAM and power-table files, the factory OCA2, WCAL and BCAL calibrations from BWC2, the J7CF records from IZUBA_wifi.cfg, the Bluetooth firmware and its .ptx, and the Bluetooth address from /chosen. Each input is validated. The radios are experimental, so a failed collection is logged and the install goes on without them. Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/src/omarchy_asahi.py | 24 +- Engine/overlay/src/omarchy_mt7932.py | 283 ++++++++++++++++++++ Engine/overlay/tests/test_omarchy_asahi.py | 36 +++ Engine/overlay/tests/test_omarchy_mt7932.py | 233 ++++++++++++++++ Engine/source-lock.json | 14 +- 5 files changed, 587 insertions(+), 3 deletions(-) create mode 100644 Engine/overlay/src/omarchy_mt7932.py create mode 100644 Engine/overlay/tests/test_omarchy_mt7932.py diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index 497f0be..c8cf9d8 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -5,6 +5,7 @@ import hashlib import io import json +import logging import os import re import stat @@ -20,6 +21,7 @@ import osinstall import stub +import omarchy_mt7932 import omarchy_planner from omarchy_image import ( WRITE_VERIFICATION, flush_device, hash_target, open_target, timing, write_image, @@ -469,7 +471,9 @@ def load_identity_naming_the_restore_bundle(): def collect_firmware_from_an_encrypted_recovery(pkg): with _newer_trackpad_keys(): - return _collect_firmware(pkg) + result = _collect_firmware(pkg) + _collect_neo_radios(installer, pkg) + return result def _collect_firmware(pkg): image = os.path.join( @@ -518,6 +522,24 @@ def install_files_with_decrypted_images_and_omarchys_step2(cur_os): return installer +def _collect_neo_radios(installer, pkg, collect=omarchy_mt7932.collect): + """Add a MacBook Neo's MT7932 Wi-Fi and Bluetooth inputs to its firmware. + + The radios are experimental: without these files the Neo still installs + and boots, so a failure is logged and the install goes on. + """ + if getattr(getattr(installer, "sysinfo", None), "device_class", "") != "j700ap": + return + try: + files = collect() + except (OSError, ValueError, subprocess.CalledProcessError, omarchy_mt7932.Mt7932Error) as error: + logging.warning("MacBook Neo radio firmware was not collected: %s", error) + return + for name, data in sorted(files.items()): + pkg.add_file(name, asahi_firmware.core.FWFile(name, data)) + logging.info("MacBook Neo radio firmware: %d files", len(files)) + + @contextlib.contextmanager def _newer_trackpad_keys(): """Count C1FE multitouch keys (the J700's trackpad) as trackpads. diff --git a/Engine/overlay/src/omarchy_mt7932.py b/Engine/overlay/src/omarchy_mt7932.py new file mode 100644 index 0000000..ebcf093 --- /dev/null +++ b/Engine/overlay/src/omarchy_mt7932.py @@ -0,0 +1,283 @@ +"""Collect the MacBook Neo's MediaTek MT7932 radio inputs from macOS. + +aurora-silicon/linux's mt7932-fullmac (Wi-Fi) and mt7932_bt_pcie (Bluetooth) +drivers, merged into aurora-wip by 3bb0a6104a11, load these from +/lib/firmware; Documentation/networking/device_drivers/wifi/mt7932-neo.rst +lists them. Each comes from this Mac's own macOS: + +- the Wi-Fi firmware and ppr.bin: the AppleSunriseWLAN driver extension; +- oca2.bin, wcal.bin and the Bluetooth calibration: the OCA2, WCAL and BCAL + fields of the factory BWC2 record, a signed IMG4 unique to this Mac; +- config-original.bin (J7CF): the driver extension's IZUBA_wifi.cfg; +- the Bluetooth firmware and PTX: /usr/share/firmware/bluetooth; +- the Bluetooth address: the device tree's /chosen. + +The country policy (policy/world-XZ.bin, J7RP) is not derived here yet, so +Wi-Fi does not start without it; Bluetooth does not need it. + +Every output is checked the way the drivers check it before it is used. +""" + +import glob +import os +import plistlib +import struct +import subprocess + +IZUBA = "System/Library/DriverExtensions/com.apple.AppleSunriseWLAN.dext/IZUBA" +FACTORY_DATA = "System/Volumes/Hardware/FactoryData/System/Library/Caches/com.apple.factorydata" +BLUETOOTH = "usr/share/firmware/bluetooth" + +WIFI_PATCH = "IZUBA_WIFI_MT7932_patch_mcu_1_2_hdr.bin" +WIFI_RAM = "IZUBA_W7932_2.bin" +# The driver loads B0 or B1 by the chip's ROM; for B1 it prefers a newer +# build that macOS 26.6 does not ship, then this one. +BT_FIRMWARE = ( + "MT7932B0_OS_TypeB_0.1.44.0_241001003711.bin", + "MT7932B1_OS_TypeB_0.1.133.0_260128190103.bin", +) +BT_TRAILER = b"ALPS\x8a\x10\x8a\x10" +BT_PTX = "MT7932_PTB_IzubaA_0.1.0.0_20251021141303.ptx" + +PPR_BYTES = 412 +WCAL_MAXIMUM = 1024 +BTCAL_MAXIMUM = 0xFFFF +J7CF_RECORD = struct.Struct(" len(data): + raise Mt7932Error("truncated DER element") + tag, cursor = data[at], at + 1 + if tag & 0x1F == 0x1F: + while cursor < len(data) and data[cursor] & 0x80: + cursor += 1 + cursor += 1 + if cursor >= len(data): + raise Mt7932Error("truncated DER tag") + length = data[cursor] + cursor += 1 + if length & 0x80: + count = length & 0x7F + if not 1 <= count <= 4 or cursor + count > len(data): + raise Mt7932Error("bad DER length") + length = int.from_bytes(data[cursor : cursor + count], "big") + cursor += count + header = cursor - at + end = at + header + length + if end > len(data): + raise Mt7932Error("DER element runs past its container") + return tag, at + header, end + + +def _children(data, start, end): + while start < end: + child = _tlv(data, start) + yield child + start = child[2] + + +def bwc2_fields(image): + """The tagged fields of a BWC2 IMG4: {"OCA2": bytes, "WCAL": bytes, ...}. + + The IM4P payload is DER. Each field is a SEQUENCE of a four-byte INTEGER + holding its tag in little-endian order, an IA5String and an OCTET STRING + holding the field itself. + """ + _, start, end = _tlv(image, 0) + parts = list(_children(image, start, end)) + if not parts or image[parts[0][1] : parts[0][2]] != b"IMG4": + raise Mt7932Error("BWC2 is not an IMG4") + im4p = list(_children(image, parts[1][1], parts[1][2])) + if [image[s:e] for _, s, e in im4p[:2]] != [b"IM4P", b"BWC2"]: + raise Mt7932Error("BWC2's IM4P is not of type BWC2") + payload = im4p[3] + if payload[0] != 0x04: + raise Mt7932Error("BWC2 has no payload") + fields = {} + + def walk(start, end): + for tag, cstart, cend in _children(image, start, end): + if not tag & 0x20: + continue + inner = list(_children(image, cstart, cend)) + if ( + tag == 0x30 + and len(inner) == 3 + and inner[0][0] == 0x02 + and inner[0][2] - inner[0][1] == 4 + and inner[1][0] == 0x16 + and inner[2][0] == 0x04 + ): + name = image[inner[0][1] : inner[0][2]][::-1].decode("ascii", "replace") + if name in fields: + raise Mt7932Error(f"BWC2 holds {name} twice") + fields[name] = bytes(image[inner[2][1] : inner[2][2]]) + else: + walk(cstart, cend) + + walk(payload[1], payload[2]) + return fields + + +def validate_oca2(data): + """mt7932_cal_validate() from the driver: a whole big-endian BLOB.""" + size = len(data) + if size < 16 or size > 16 << 20 or data[:4] != b"BLOB": + raise Mt7932Error("oca2 is not a BLOB") + end, header, count = struct.unpack_from(">IHH", data, 4) + if header != 12 or not count or end != 16 + 20 * count or end > size: + raise Mt7932Error("oca2's BLOB header is inconsistent") + spans = [] + for index in range(count): + entry = data[16 + 20 * index : 36 + 20 * index] + kind, entry_header, offset, length, checksum = struct.unpack_from(">HHIII", entry) + if entry_header != 12 or offset < end or offset > size or length < 12 or length > size - offset: + raise Mt7932Error(f"oca2 entry {index} is out of bounds") + if data[offset : offset + 4] != entry[:4] or struct.unpack_from(">I", data, offset + 4)[0] != length: + raise Mt7932Error(f"oca2 entry {index} does not match its record") + if sum(data[offset : offset + length]) & 0xFFFFFFFF != checksum: + raise Mt7932Error(f"oca2 entry {index} has a bad checksum") + for prior_kind, start, bytes_ in spans: + if prior_kind == kind or (offset < start + bytes_ and start < offset + length): + raise Mt7932Error(f"oca2 entry {index} repeats or overlaps another") + spans.append((kind, offset, length)) + + +def validate_bluetooth_firmware(data): + """bt7932_read_inputs(): a 32-byte trailer carrying the ALPS marker.""" + if len(data) <= 32 or data[-16:-8] != BT_TRAILER: + raise Mt7932Error("the Bluetooth firmware has no ALPS trailer") + + +def validate_ptx(data): + """bt7932_validate_ptx() from the Bluetooth driver.""" + if len(data) != 198 or data[:4] != b"BLOB": + raise Mt7932Error("the PTX is not a 198-byte BLOB") + first, version, count, reserved = struct.unpack_from("= 32 or b"\0" in key_bytes + value_bytes: + raise Mt7932Error(f"Wi-Fi setting {key!r} does not fit a J7CF record") + if key in keys: + raise Mt7932Error(f"Wi-Fi setting {key!r} appears twice") + keys.add(key) + records.append(J7CF_RECORD.pack(3, len(key_bytes), len(value_bytes), 0, key_bytes, value_bytes)) + if len(records) not in (64, 65): + raise Mt7932Error(f"IZUBA_wifi.cfg has {len(records)} settings, not 64 or 65") + for required in ("Sta5gBw", "DisRoaming"): + if required not in keys: + raise Mt7932Error(f"IZUBA_wifi.cfg has no {required}") + return b"J7CF" + struct.pack("HHI', kind, 12, length) + body + records += struct.pack('>HHIII', kind, 12, offset, length, sum(chunk)) + b'\0' * 4 + bodies += chunk + offset += length + return b'BLOB' + struct.pack('>IHH', end, 12, len(entries)) + b'\0' * 4 + records + bodies + + +def ptx(): + """A 198-byte little-endian BLOB that passes bt7932_validate_ptx().""" + sections = ((0x101, b'p' * 14), (0x201, b'q' * 44), (0x301, b'r' * 44), (0x401, b'')) + records, bodies, offset = b'', b'', 96 + for kind, body in sections: + records += struct.pack(' Date: Sun, 4 Oct 2026 20:22:51 -0400 Subject: [PATCH 07/10] Rebuild one engine with the Neo and macOS 26 overlays, as .29 installer-v0.9.2-omarchy.29.tar.gz, 17,851,067 bytes, SHA-256 14323c78..., reproduced twice with macOS /usr/bin/python3 3.9.6 from a fresh v0.9.2 checkout that also reproduces .28. Compared with .28 it adds omarchy_mt7932.py and changes main.py, omarchy_asahi.py and version.tag. The bundled inspection pin, the packager and both release templates select it, so the release scripts publish the engine the catalog names. Co-Authored-By: Claude Opus 5.5 --- Engine/source-lock.json | 6 +++--- Packaging/build-app.sh | 4 ++-- .../OmarchyAppleInstaller/ValidationEngineArtifact.swift | 8 ++++---- .../ValidationEngineArtifactTests.swift | 8 ++++---- docs/extraction.md | 6 +++++- scripts/release-inputs-aurora.template.json | 4 ++-- scripts/release-inputs.template.json | 4 ++-- 7 files changed, 22 insertions(+), 18 deletions(-) diff --git a/Engine/source-lock.json b/Engine/source-lock.json index d3bbe6e..e653e16 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -284,9 +284,9 @@ } ], "validation_artifact": { - "filename": "installer-v0.9.2-omarchy.28.tar.gz", - "size_bytes": 17843348, - "sha256": "0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146", + "filename": "installer-v0.9.2-omarchy.29.tar.gz", + "size_bytes": 17851067, + "sha256": "14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337", "reproducibility_scope": "two-identical-python-overlay-repacks-authenticated-base", "signature": "absent", "metadata_sha256": "2e6181ce6b6e17c11039e04bfadade8d10ae0e11889885ad8c9960b68f179a5d" diff --git a/Packaging/build-app.sh b/Packaging/build-app.sh index 04be512..a4b5301 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.28.tar.gz" -engine_digest="0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" +engine_file_name="installer-v0.9.2-omarchy.29.tar.gz" +engine_digest="14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337" if [[ $signing_identity == "-" ]]; then client_requirement="identifier \"$app_identifier\"" diff --git a/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift b/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift index 0396ad7..a86e134 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.28" - public static let fileName = "installer-v0.9.2-omarchy.28.tar.gz" + public static let version = "v0.9.2-omarchy.29" + public static let fileName = "installer-v0.9.2-omarchy.29.tar.gz" public static let expectedDigest = - "sha256:0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" - public static let expectedSizeBytes: UInt64 = 17_843_348 + "sha256:14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337" + public static let expectedSizeBytes: UInt64 = 17_851_067 public init() {} diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift index fafa9b5..3b386ae 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift @@ -8,19 +8,19 @@ func testM3CapableInspectionIdentityIsPinnedExactly() { XCTAssertEqual( ValidationEngineArtifactLocator.version, - "v0.9.2-omarchy.28" + "v0.9.2-omarchy.29" ) XCTAssertEqual( ValidationEngineArtifactLocator.fileName, - "installer-v0.9.2-omarchy.28.tar.gz" + "installer-v0.9.2-omarchy.29.tar.gz" ) XCTAssertEqual( ValidationEngineArtifactLocator.expectedDigest, - "sha256:0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" + "sha256:14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337" ) XCTAssertEqual( ValidationEngineArtifactLocator.expectedSizeBytes, - 17_843_348 + 17_851_067 ) } diff --git a/docs/extraction.md b/docs/extraction.md index 3f638f4..f611407 100644 --- a/docs/extraction.md +++ b/docs/extraction.md @@ -81,4 +81,8 @@ Swift now withholds the resize drift margin before adopting the recommended (dou 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 `.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. +## macOS 26 firmware and MacBook Neo engine + +Engine `.29`, `installer-v0.9.2-omarchy.29.tar.gz`, 17,851,067 bytes, SHA-256 `14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337`, is `.28` plus macOS 26 firmware support and the MacBook Neo (`apple,j700`): reading the restore bundle macOS 26's bootcaches omit, collecting firmware and boot images from the running macOS when the recovery image is AEA-encrypted, the Neo's own Aurora Stage 1, its C1FE trackpad firmware, and its MT7932 Wi-Fi and Bluetooth inputs collected from macOS. Two repacks with macOS `/usr/bin/python3` 3.9.6 from a fresh v0.9.2 checkout produced identical bytes, after the same checkout reproduced `.28` exactly. Compared with `.28`, `omarchy_mt7932.py` is added and `main.py`, `omarchy_asahi.py` and `version.tag` changed. The bundled inspection pins, packager and both release templates select `.29`. Public catalogs still do not admit `apple,j700`; only a developer build with a sealed developer catalog can. + +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 `.29`; 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. diff --git a/scripts/release-inputs-aurora.template.json b/scripts/release-inputs-aurora.template.json index 12896f5..684ec06 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.28.tar.gz", + "engine_name": "installer-v0.9.2-omarchy.29.tar.gz", "metadata_name": "installer_data.json", - "engine_version": "v0.9.2-omarchy.28", + "engine_version": "v0.9.2-omarchy.29", "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 a72dd84..0c04ca6 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.28.tar.gz", + "engine_name": "installer-v0.9.2-omarchy.29.tar.gz", "metadata_name": "installer_data.json", - "engine_version": "v0.9.2-omarchy.28", + "engine_version": "v0.9.2-omarchy.29", "evidence_revision": "4.0.3-mac.1.20260913", "asahi_installer_tag": "v0.9.2", "asahi_installer_revision": "dffbb38ef0c00c0431c609ecd8a00f42deb5b24c", From 9679013eac1987757bd105dfc34bac9b262e112c Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Sun, 4 Oct 2026 21:46:46 -0400 Subject: [PATCH 08/10] Recognize a MacBook Neo stub that boots Aurora's J700 Stage 1 Removal links a startup container to its EFI partition through the ESP its m1n1 boot object names. Aurora's J700 Stage 1, which the Neo boots and engine .29 installs, keeps that in a versioned, CRC-checked config block and ends at STACKBOT, so removal found no ESP and refused the Neo's own installation. Exactly one valid version-1 block now names the ESP; asahi's m1n1 variables remain the fallback. Co-Authored-By: Claude Opus 5.5 --- .../OmarchyRemovalRecognition.swift | 43 ++++++++++++++++- .../OmarchyRemovalTests.swift | 48 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift b/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift index 9177767..e8c2455 100644 --- a/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift +++ b/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift @@ -254,9 +254,13 @@ return created } - /// m1n1's variables follow the first "STACKBOT" up to the first NUL, one per - /// line (asahi-installer m1n1.py extract_vars). nil when there is no such region. + /// The EFI partitions a startup container's m1n1 chainloads. Aurora's J700 + /// Stage 1 (the MacBook Neo) names its one ESP in a versioned, CRC-checked + /// config block; asahi's m1n1 keeps variables after the first "STACKBOT" up + /// to the first NUL, one per line (asahi-installer m1n1.py extract_vars). + /// nil when neither is there. static func efiPartitions(bootObject: Data) -> [String]? { + if let aurora = auroraStage1Partition(bootObject: bootObject) { return [aurora] } guard let marker = bootObject.range(of: Data("STACKBOT".utf8)) else { return nil } let tail = bootObject[marker.upperBound...] let region = tail.prefix { $0 != 0 } @@ -265,6 +269,41 @@ return String(decoding: region, as: UTF8.self).split(separator: "\n") .filter { $0.hasPrefix(key) }.map { String($0.dropFirst(key.count)) } } + + /// Aurora's J700 Stage 1 config block (aurora-silicon/m1n1 + /// tools/fill_stage1_config.py): magic, then version, proxy window, a + /// NUL-padded 40-byte ESP PARTUUID and a 192-byte Stage 2 path, then the + /// CRC-32 of those fields. nil unless there is exactly one valid version-1 + /// block naming an ESP. + static func auroraStage1Partition(bootObject: Data) -> String? { + let magic = Data("AURORA-S1-CFG01\0".utf8) + let bodySize = 4 + 4 + 40 + 192 + let bytes = [UInt8](bootObject) + guard let first = bootObject.range(of: magic), + bootObject.range(of: magic, in: first.upperBound.. UInt32 { + (0..<4).reduce(UInt32(0)) { $0 | UInt32(bytes[offset + $1]) << (8 * $1) } + } + guard word(start) == 1, word(start + bodySize) == crc32(body) else { return nil } + let field = body[8..<48].prefix { $0 != 0 } + guard body[(8 + field.count)..<48].allSatisfy({ $0 == 0 }), + let uuid = UUID(uuidString: String(decoding: field, as: UTF8.self)) + else { return nil } + return uuid.uuidString + } + + static func crc32(_ bytes: [UInt8]) -> UInt32 { + var crc: UInt32 = 0xFFFF_FFFF + for byte in bytes { + crc ^= UInt32(byte) + for _ in 0..<8 { crc = crc & 1 == 1 ? (crc >> 1) ^ 0xEDB8_8320 : crc >> 1 } + } + return ~crc + } } /// Every message says what was found and ends by saying nothing changed. diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift index acdcb06..d34c750 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift @@ -28,6 +28,54 @@ [F.convergedInstall.stub, F.convergedInstall.esp] + F.convergedInstall.linux) } + func testANeoStubBootingAurorasJ700Stage1IsRecognised() throws { + let disk = FakeRemovalDisk(F.neoMacos26(), install: F.convergedInstall) + disk.files!.stubBootObject = Self.auroraStage1(esp: F.convergedInstall.esp.lowercased()) + let plan = try OmarchyRemovalPlan(disks: disk) + XCTAssertEqual(plan.kind, .installation) + XCTAssertEqual( + plan.members.map(\.uuid), + [F.convergedInstall.stub, F.convergedInstall.esp] + F.convergedInstall.linux) + } + + func testAnAuroraStage1MustNameThisESPInOneValidBlock() { + let esp = F.convergedInstall.esp.lowercased() + let versionTwo = Self.auroraStage1(esp: esp, version: 2) + let cases: [Data] = [ + Self.auroraStage1(esp: F.id(77).lowercased()), + Self.auroraStage1(esp: esp, corruptCRC: true), + versionTwo, + Self.auroraStage1(esp: esp) + Self.auroraStage1(esp: esp), + ] + for bootObject in cases { + let disk = FakeRemovalDisk(F.neoMacos26(), install: F.convergedInstall) + disk.files!.stubBootObject = bootObject + XCTAssertThrowsError(try OmarchyRemovalPlan(disks: disk)) { + XCTAssertTrue( + ($0 as? RemovalFailure)?.message.contains( + "the m1n1 boot object in its startup container doesn’t point to this EFI partition") + ?? false, "\($0)") + } + } + } + + /// Aurora's J700 Stage 1 as aurora-silicon/m1n1 tools/fill_stage1_config.py + /// fills it: asahi's m1n1 version marker, one config block, a STACKBOT tail. + static func auroraStage1(esp: String, version: UInt32 = 1, corruptCRC: Bool = false) -> Data { + func le(_ value: UInt32) -> [UInt8] { + (0..<4).map { UInt8(truncatingIfNeeded: value >> (8 * $0)) } + } + var body = le(version) + le(5000) + body += Array(esp.utf8) + [UInt8](repeating: 0, count: 40 - esp.utf8.count) + let path = Array(";m1n1/boot.bin".utf8) + body += path + [UInt8](repeating: 0, count: 192 - path.count) + var image = Data("m1n1 stage 1 ##m1n1_ver##v1.6.1\0 code STACKBOT data ".utf8) + image.append(Data("AURORA-S1-CFG01\0".utf8)) + image.append(contentsOf: body + le(RemovalEvidence.crc32(body) ^ (corruptCRC ? 1 : 0))) + image.append(Data(" more code STACKBOT".utf8)) + return image + } + func testOlderOmarchyMacInstallIsRecognisedWithItsOneRootPartition() throws { let disk = FakeRemovalDisk(F.alarm(), install: F.alarmInstall) let plan = try OmarchyRemovalPlan(disks: disk) From 306e3ee1e2928151cb8c15703b4aae934b49dcf7 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Mon, 5 Oct 2026 23:03:15 -0400 Subject: [PATCH 09/10] Collect a MacBook Neo's Touch ID calibration from macOS, as engine .30 The Neo keeps its Mesa calibration in the iBoot System Container as one standalone signed IMG4 of type FSC2, which aurora-touchid's extractor did not find. On a j700ap the engine now reads the raw container read-only, takes the one FSC2 image with a CALB payload and an IM4M manifest, and adds it to the vendor firmware as apple/mesacal-j700.bin, the name the Neo's device tree gives the driver. Like the radios, a failure is logged and the install goes on. installer-v0.9.2-omarchy.30.tar.gz, 17,852,406 bytes, SHA-256 2d5a14c3..., reproduced twice with macOS /usr/bin/python3 3.9.6; it adds omarchy_mesa.py and changes omarchy_asahi.py and version.tag. The bundled pin, packager and both release templates select it. Co-Authored-By: Claude Opus 5.5 --- Engine/overlay/src/omarchy_asahi.py | 29 +++++ Engine/overlay/src/omarchy_mesa.py | 106 ++++++++++++++++++ Engine/overlay/tests/test_omarchy_asahi.py | 51 +++++++++ Engine/overlay/tests/test_omarchy_mesa.py | 63 +++++++++++ Engine/rebuild-python-overlay.py | 2 +- Engine/source-lock.json | 24 ++-- Packaging/build-app.sh | 4 +- .../ValidationEngineArtifact.swift | 8 +- .../ValidationEngineArtifactTests.swift | 8 +- docs/extraction.md | 6 +- scripts/release-inputs-aurora.template.json | 4 +- scripts/release-inputs.template.json | 4 +- 12 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 Engine/overlay/src/omarchy_mesa.py create mode 100644 Engine/overlay/tests/test_omarchy_mesa.py diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index c8cf9d8..1aa2f75 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -21,6 +21,7 @@ import osinstall import stub +import omarchy_mesa import omarchy_mt7932 import omarchy_planner from omarchy_image import ( @@ -473,6 +474,7 @@ def collect_firmware_from_an_encrypted_recovery(pkg): with _newer_trackpad_keys(): result = _collect_firmware(pkg) _collect_neo_radios(installer, pkg) + _collect_neo_touch_id(installer, pkg) return result def _collect_firmware(pkg): @@ -540,6 +542,33 @@ def _collect_neo_radios(installer, pkg, collect=omarchy_mt7932.collect): logging.info("MacBook Neo radio firmware: %d files", len(files)) +def _iboot_system_container(dutil): + """The raw device of the system disk's iBoot System Container, its first + partition (asahi-installer's find_system_disk test).""" + partition = dutil.disk_parts[dutil.find_system_disk()]["Partitions"][0] + if partition.get("Content") != "Apple_APFS_ISC": + raise omarchy_mesa.MesaError("the system disk does not start with its iBoot System Container") + return "/dev/r" + partition["DeviceIdentifier"] + + +def _collect_neo_touch_id(installer, pkg, collect=omarchy_mesa.collect): + """Add a MacBook Neo's Touch ID calibration to its firmware. + + Touch ID on the Neo is experimental too: without the calibration the Neo + installs and boots, so a failure is logged and the install goes on. + """ + if getattr(getattr(installer, "sysinfo", None), "device_class", "") != "j700ap": + return + try: + files = collect(_iboot_system_container(installer.dutil)) + except (OSError, KeyError, IndexError, omarchy_mesa.MesaError) as error: + logging.warning("MacBook Neo Touch ID calibration was not collected: %s", error) + return + for name, data in sorted(files.items()): + pkg.add_file(name, asahi_firmware.core.FWFile(name, data)) + logging.info("MacBook Neo Touch ID calibration: %d bytes", sum(map(len, files.values()))) + + @contextlib.contextmanager def _newer_trackpad_keys(): """Count C1FE multitouch keys (the J700's trackpad) as trackpads. diff --git a/Engine/overlay/src/omarchy_mesa.py b/Engine/overlay/src/omarchy_mesa.py new file mode 100644 index 0000000..fd9427d --- /dev/null +++ b/Engine/overlay/src/omarchy_mesa.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: MIT +"""A MacBook Neo's Touch ID (Mesa) calibration, read from macOS. + +Older Macs keep the sensor calibration in a comb/fdrd/secb record that +aurora-touchid's extractor reads on Linux. Newer ones, such as the MacBook +Neo, store it in the iBoot System Container as one standalone signed IMG4 +whose IM4P type is FSC2. The apple_sep driver loads that IMG4 as the +firmware its device tree names, apple/mesacal-j700.bin on the Neo. +""" + +import os + +FIRMWARE_NAME = "apple/mesacal-j700.bin" +# The iBoot System Container is about 550 MB; refuse anything far larger. +CONTAINER_MAXIMUM = 1 << 30 + + +class MesaError(Exception): + pass + + +def _tlv(data, at): + tag, length, header = data[at], data[at + 1], 2 + if length >= 0x80: + count = length & 0x7F + if not 1 <= count <= 4: + raise ValueError("bad DER length") + length = int.from_bytes(data[at + 2 : at + 2 + count], "big") + header = 2 + count + end = at + header + length + if end > len(data): + raise ValueError("DER runs past the input") + return tag, at + header, end + + +def _children(data, start, end): + while start < end: + child = _tlv(data, start) + yield child + start = child[2] + + +def _ia5(data, child): + tag, start, end = child + return bytes(data[start:end]) if tag == 0x16 else None + + +def fsc2_calibrations(data): + """Every distinct signed FSC2 IMG4 (IM4P payload holding CALB, IM4M + manifest) in data, as bytes.""" + found = set() + at = data.find(b"\x16\x04IMG4") + while at >= 0: + for header in range(2, 7): + start = at - header + if start < 0 or data[start] != 0x30: + continue + try: + _, content, end = _tlv(data, start) + if content != at: + continue + parts = list(_children(data, content, end)) + im4p = list(_children(data, parts[1][1], parts[1][2])) + if ( + _ia5(data, parts[0]) == b"IMG4" + and _ia5(data, im4p[0]) == b"IM4P" + and _ia5(data, im4p[1]) == b"FSC2" + and im4p[3][0] == 0x04 + and b"CALB" in data[im4p[3][1] : im4p[3][1] + 256] + and len(parts) >= 3 + and parts[2][0] == 0xA0 + and b"IM4M" in data[parts[2][1] : parts[2][2]] + ): + found.add(bytes(data[start:end])) + except (ValueError, IndexError): + continue + at = data.find(b"\x16\x04IMG4", at + 1) + return found + + +def calibration(container): + """The one signed FSC2 calibration in an iBoot System Container image.""" + found = fsc2_calibrations(container) + if len(found) != 1: + raise MesaError(f"expected one signed FSC2 calibration, found {len(found)}") + return found.pop() + + +def collect(device): + """{firmware path: bytes} for this Mac's Touch ID, read from the raw + iBoot System Container device, never written.""" + fd = os.open(device, os.O_RDONLY) + try: + size = os.lseek(fd, 0, os.SEEK_END) + if not 0 < size <= CONTAINER_MAXIMUM: + raise MesaError(f"{device} is {size} bytes, not an iBoot System Container") + os.lseek(fd, 0, os.SEEK_SET) + container = bytearray() + while len(container) < size: + block = os.read(fd, min(8 << 20, size - len(container))) + if not block: + break + container += block + finally: + os.close(fd) + return {FIRMWARE_NAME: calibration(container)} diff --git a/Engine/overlay/tests/test_omarchy_asahi.py b/Engine/overlay/tests/test_omarchy_asahi.py index a322924..9abc2ad 100644 --- a/Engine/overlay/tests/test_omarchy_asahi.py +++ b/Engine/overlay/tests/test_omarchy_asahi.py @@ -111,6 +111,7 @@ def prepare_for_step2(self): from omarchy_asahi import ( # noqa: E402 _collect_neo_radios, + _collect_neo_touch_id, STEP2_SCRIPT, AsahiAdapterError, AsahiInPlaceRepairAdapter, @@ -1617,5 +1618,55 @@ def collect(): self.assertEqual(self.run_hook("j700ap", collect), []) self.assertIn("no WCAL", logs.output[0]) +class NeoTouchIdCalibrationTests(unittest.TestCase): + """The MacBook Neo's FSC2 calibration joins its vendor firmware package.""" + + def run_hook(self, device_class, collect, partitions=None): + package = SimpleNamespace(added=[]) + package.add_file = lambda name, data: package.added.append((name, data)) + dutil = SimpleNamespace( + find_system_disk=lambda: "disk0", + disk_parts={"disk0": {"Partitions": partitions or [ + {"Content": "Apple_APFS_ISC", "DeviceIdentifier": "disk0s1"}, + {"Content": "Apple_APFS", "DeviceIdentifier": "disk0s2"}, + ]}}, + ) + installer = SimpleNamespace(sysinfo=SimpleNamespace(device_class=device_class), dutil=dutil) + firmware = SimpleNamespace(core=SimpleNamespace(FWFile=lambda name, data: ("FWFile", name, data))) + with patch("omarchy_asahi.asahi_firmware", firmware): + _collect_neo_touch_id(installer, package, collect=collect) + return package.added + + def test_a_neo_reads_the_raw_iboot_system_container(self): + devices = [] + + def collect(device): + devices.append(device) + return {"apple/mesacal-j700.bin": b"img4"} + + self.assertEqual(self.run_hook("j700ap", collect), [ + ("apple/mesacal-j700.bin", ("FWFile", "apple/mesacal-j700.bin", b"img4")), + ]) + self.assertEqual(devices, ["/dev/rdisk0s1"]) + + def test_other_macs_collect_nothing(self): + self.assertEqual(self.run_hook("j613ap", lambda device: self.fail("read the container")), []) + + def test_a_failed_collection_leaves_the_install_going(self): + import omarchy_mesa + + def collect(device): + raise omarchy_mesa.MesaError("expected one signed FSC2 calibration, found 0") + + with self.assertLogs(level="WARNING") as logs: + self.assertEqual(self.run_hook("j700ap", collect), []) + self.assertIn("found 0", logs.output[0]) + + def test_a_disk_without_its_container_first_is_not_read(self): + partitions = [{"Content": "Apple_APFS", "DeviceIdentifier": "disk0s1"}] + with self.assertLogs(level="WARNING"): + self.assertEqual(self.run_hook("j700ap", lambda device: self.fail("read"), partitions), []) + + if __name__ == "__main__": unittest.main() diff --git a/Engine/overlay/tests/test_omarchy_mesa.py b/Engine/overlay/tests/test_omarchy_mesa.py new file mode 100644 index 0000000..ca39c94 --- /dev/null +++ b/Engine/overlay/tests/test_omarchy_mesa.py @@ -0,0 +1,63 @@ +import os +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from omarchy_mesa import MesaError, calibration, collect # noqa: E402 + + +def der(tag, body): + if len(body) < 0x80: + return bytes([tag, len(body)]) + body + size = len(body).to_bytes((len(body).bit_length() + 7) // 8, "big") + return bytes([tag, 0x80 | len(size)]) + size + body + + +def ia5(text): + return der(0x16, text.encode()) + + +def img4(kind=b"FSC2", payload=b"CALB" + b"\x5a" * 300, manifest=b"IM4M"): + im4p = der(0x30, ia5("IM4P") + der(0x16, kind) + ia5("mesa") + der(0x04, payload)) + return der(0x30, ia5("IMG4") + im4p + der(0xA0, der(0x30, der(0x16, manifest)))) + + +class Fsc2CalibrationTests(unittest.TestCase): + def test_the_one_signed_fsc2_image_is_returned_whole(self): + blob = img4() + container = b"\0" * 4096 + blob + b"\xff" * 4096 + self.assertEqual(calibration(container), blob) + + def test_the_same_image_stored_twice_counts_once(self): + blob = img4() + self.assertEqual(calibration(blob + b"\0" * 512 + blob), blob) + + def test_other_images_are_ignored(self): + container = img4(kind=b"FSCl") + img4(payload=b"none" * 80) + img4(manifest=b"NOPE") + with self.assertRaisesRegex(MesaError, "found 0"): + calibration(container) + + def test_two_different_calibrations_are_refused(self): + with self.assertRaisesRegex(MesaError, "found 2"): + calibration(img4() + img4(payload=b"CALB" + b"\x33" * 300)) + + def test_collect_reads_the_device_and_names_the_neos_firmware(self): + blob = img4() + with tempfile.TemporaryDirectory() as root: + device = os.path.join(root, "rdisk0s1") + Path(device).write_bytes(b"\0" * 9000 + blob + b"\0" * 9000) + self.assertEqual(collect(device), {"apple/mesacal-j700.bin": blob}) + + def test_an_empty_device_is_refused(self): + with tempfile.TemporaryDirectory() as root: + device = os.path.join(root, "rdisk0s1") + Path(device).write_bytes(b"") + with self.assertRaisesRegex(MesaError, "not an iBoot System Container"): + collect(device) + + +if __name__ == "__main__": + unittest.main() diff --git a/Engine/rebuild-python-overlay.py b/Engine/rebuild-python-overlay.py index bbaed91..aee2624 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.29' +VERSION = 'v0.9.2-omarchy.30' _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 e653e16..46b60e3 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -141,7 +141,7 @@ } }, "downstream_overlay": { - "version": "v0.9.2-omarchy.29", + "version": "v0.9.2-omarchy.30", "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": "645a4c8823e395073f00cf46ff4588b21d88ebfdac65d4116576dce98da1ff70" + "sha256": "0efb7ffb77ce826389eb324bd8f7c09f44c40f83a32adad91badc63d276f357d" }, { "path": "overlay/src/omarchy_contract.py", @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "4119a5bd677b075392f4cee4c9f63cd71b9e5b7404429514f6799e89afc4e261" + "sha256": "aead11015d20ca12737f516806e7576e5da436f6d47f16b7307d56e1ab3a24d7" }, { "path": "overlay/tests/test_omarchy_contract.py", @@ -262,6 +262,16 @@ "path": "overlay/tests/test_omarchy_mt7932.py", "destination": "tests/test_omarchy_mt7932.py", "sha256": "b9668eb4a6407f35cfdeb9addd061190720208d5ee1cbb4f969cb6499aa6a822" + }, + { + "path": "overlay/src/omarchy_mesa.py", + "destination": "src/omarchy_mesa.py", + "sha256": "fcdb678568bb33d8f8cf2275f11b16d0b66409c5bd27732133d5e728bb06983b" + }, + { + "path": "overlay/tests/test_omarchy_mesa.py", + "destination": "tests/test_omarchy_mesa.py", + "sha256": "372d07d0e1860299bdf0a56b834d60748b5e130a2ef185f53ff9e8f12b3e790a" } ] }, @@ -280,13 +290,13 @@ }, { "path": "rebuild-python-overlay.py", - "sha256": "b56c1b76976aeaa4a9beee1db810a155bb270fbf62522d43eea7a195a6212dc4" + "sha256": "324c324ffde5c68294890243010e7764b44849df81c9f1605ec8bbe00c13e65c" } ], "validation_artifact": { - "filename": "installer-v0.9.2-omarchy.29.tar.gz", - "size_bytes": 17851067, - "sha256": "14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337", + "filename": "installer-v0.9.2-omarchy.30.tar.gz", + "size_bytes": 17852406, + "sha256": "2d5a14c3dde7b9ebb7076cd65a6d5a478d7f20b6396fadb52b59532752d12bdc", "reproducibility_scope": "two-identical-python-overlay-repacks-authenticated-base", "signature": "absent", "metadata_sha256": "2e6181ce6b6e17c11039e04bfadade8d10ae0e11889885ad8c9960b68f179a5d" diff --git a/Packaging/build-app.sh b/Packaging/build-app.sh index a4b5301..afe563c 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.29.tar.gz" -engine_digest="14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337" +engine_file_name="installer-v0.9.2-omarchy.30.tar.gz" +engine_digest="2d5a14c3dde7b9ebb7076cd65a6d5a478d7f20b6396fadb52b59532752d12bdc" if [[ $signing_identity == "-" ]]; then client_requirement="identifier \"$app_identifier\"" diff --git a/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift b/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift index a86e134..55f9b18 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.29" - public static let fileName = "installer-v0.9.2-omarchy.29.tar.gz" + public static let version = "v0.9.2-omarchy.30" + public static let fileName = "installer-v0.9.2-omarchy.30.tar.gz" public static let expectedDigest = - "sha256:14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337" - public static let expectedSizeBytes: UInt64 = 17_851_067 + "sha256:2d5a14c3dde7b9ebb7076cd65a6d5a478d7f20b6396fadb52b59532752d12bdc" + public static let expectedSizeBytes: UInt64 = 17_852_406 public init() {} diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift index 3b386ae..b72692e 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift @@ -8,19 +8,19 @@ func testM3CapableInspectionIdentityIsPinnedExactly() { XCTAssertEqual( ValidationEngineArtifactLocator.version, - "v0.9.2-omarchy.29" + "v0.9.2-omarchy.30" ) XCTAssertEqual( ValidationEngineArtifactLocator.fileName, - "installer-v0.9.2-omarchy.29.tar.gz" + "installer-v0.9.2-omarchy.30.tar.gz" ) XCTAssertEqual( ValidationEngineArtifactLocator.expectedDigest, - "sha256:14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337" + "sha256:2d5a14c3dde7b9ebb7076cd65a6d5a478d7f20b6396fadb52b59532752d12bdc" ) XCTAssertEqual( ValidationEngineArtifactLocator.expectedSizeBytes, - 17_851_067 + 17_852_406 ) } diff --git a/docs/extraction.md b/docs/extraction.md index f611407..fdc43d2 100644 --- a/docs/extraction.md +++ b/docs/extraction.md @@ -83,6 +83,8 @@ The catalog's installation engine is `installer-v0.9.2-omarchy.28.tar.gz`, 17,84 ## macOS 26 firmware and MacBook Neo engine -Engine `.29`, `installer-v0.9.2-omarchy.29.tar.gz`, 17,851,067 bytes, SHA-256 `14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337`, is `.28` plus macOS 26 firmware support and the MacBook Neo (`apple,j700`): reading the restore bundle macOS 26's bootcaches omit, collecting firmware and boot images from the running macOS when the recovery image is AEA-encrypted, the Neo's own Aurora Stage 1, its C1FE trackpad firmware, and its MT7932 Wi-Fi and Bluetooth inputs collected from macOS. Two repacks with macOS `/usr/bin/python3` 3.9.6 from a fresh v0.9.2 checkout produced identical bytes, after the same checkout reproduced `.28` exactly. Compared with `.28`, `omarchy_mt7932.py` is added and `main.py`, `omarchy_asahi.py` and `version.tag` changed. The bundled inspection pins, packager and both release templates select `.29`. Public catalogs still do not admit `apple,j700`; only a developer build with a sealed developer catalog can. +Engine `.29`, `installer-v0.9.2-omarchy.29.tar.gz`, 17,851,067 bytes, SHA-256 `14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337`, is `.28` plus macOS 26 firmware support and the MacBook Neo (`apple,j700`): reading the restore bundle macOS 26's bootcaches omit, collecting firmware and boot images from the running macOS when the recovery image is AEA-encrypted, the Neo's own Aurora Stage 1, its C1FE trackpad firmware, and its MT7932 Wi-Fi and Bluetooth inputs collected from macOS. Two repacks with macOS `/usr/bin/python3` 3.9.6 from a fresh v0.9.2 checkout produced identical bytes, after the same checkout reproduced `.28` exactly. Compared with `.28`, `omarchy_mt7932.py` is added and `main.py`, `omarchy_asahi.py` and `version.tag` changed. The bundled inspection pins, packager and both release templates selected `.29`. Public catalogs still do not admit `apple,j700`; only a developer build with a sealed developer catalog can. -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 `.29`; 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. +Engine `.30`, `installer-v0.9.2-omarchy.30.tar.gz`, 17,852,406 bytes, SHA-256 `2d5a14c3dde7b9ebb7076cd65a6d5a478d7f20b6396fadb52b59532752d12bdc`, adds the MacBook Neo's Touch ID calibration to the firmware it collects: one standalone signed FSC2 IMG4 read from the raw iBoot System Container and installed as `apple/mesacal-j700.bin`. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes; compared with `.29`, `omarchy_mesa.py` is added and `omarchy_asahi.py` and `version.tag` changed. The bundled inspection pins, packager and both release templates select `.30`. + +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 `.30`; 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. diff --git a/scripts/release-inputs-aurora.template.json b/scripts/release-inputs-aurora.template.json index 684ec06..f0cb535 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.29.tar.gz", + "engine_name": "installer-v0.9.2-omarchy.30.tar.gz", "metadata_name": "installer_data.json", - "engine_version": "v0.9.2-omarchy.29", + "engine_version": "v0.9.2-omarchy.30", "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 0c04ca6..0240457 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.29.tar.gz", + "engine_name": "installer-v0.9.2-omarchy.30.tar.gz", "metadata_name": "installer_data.json", - "engine_version": "v0.9.2-omarchy.29", + "engine_version": "v0.9.2-omarchy.30", "evidence_revision": "4.0.3-mac.1.20260913", "asahi_installer_tag": "v0.9.2", "asahi_installer_revision": "dffbb38ef0c00c0431c609ecd8a00f42deb5b24c", From cdc2270f4dbd5b94a1b9dd0e1333bc7e1038cdd7 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Wed, 7 Oct 2026 09:37:26 -0400 Subject: [PATCH 10/10] Let a developer catalog give the MacBook Neo its own image make-unsigned-catalog.py --developer accepts developer_models: groups of boards from supported-models.json's new developer section, each with its own payload (split or whole) and metadata, alongside the main group, which still covers exactly every M1, M2 and M3 Mac. Refused boards stay refused, a group may not repeat a Mac or a file name, and check-catalog still rejects apple,j700, so a developer catalog can never reach a channel. Each group's digests are computed once instead of once per Mac. Co-Authored-By: Claude Opus 5.5 --- scripts/make-unsigned-catalog.py | 118 +++++++++++++++----- scripts/supported-models.json | 3 + scripts/supported_models.py | 12 +- scripts/tests/test_make_unsigned_catalog.py | 85 +++++++++++++- 4 files changed, 183 insertions(+), 35 deletions(-) diff --git a/scripts/make-unsigned-catalog.py b/scripts/make-unsigned-catalog.py index 40b8655..8ad605b 100755 --- a/scripts/make-unsigned-catalog.py +++ b/scripts/make-unsigned-catalog.py @@ -20,9 +20,14 @@ higher-sequence one replaces it. The monotonic `sequence` is the only machine-enforced guard. +`--developer` is for a developer build's sealed catalog only. Its inputs may +add `developer_models`: groups of boards from the manifest's `developer` +section, each with its own payload and metadata. The main group still covers +exactly every M1, M2 and M3 Mac. Channel publishing rejects such a catalog. + Usage: make-unsigned-catalog.py --base-url URL --assets-dir DIR --inputs FILE - [--output FILE] [--now ISO8601] + [--developer] [--output FILE] [--now ISO8601] """ from __future__ import annotations @@ -57,6 +62,7 @@ REQUIRED_INSTALLER_KEYS = frozenset( {"minimum_version", "latest_version", "download_url"} ) +DEVELOPER_GROUP_KEYS = frozenset({"payload_name", "metadata_name", "device_identifiers"}) DEVICE_IDENTIFIER_PATTERN = re.compile(r"^apple,[0-9a-z]+$") EVIDENCE_REVISION_PATTERN = re.compile(r"^[0-9a-z.-]+$") @@ -165,7 +171,42 @@ def parse_version(value: str) -> tuple[int, int, int]: return (int(parts[0]), int(parts[1]), int(parts[2])) -def load_inputs(path: Path) -> dict: +def load_developer_models(document: dict) -> list[dict]: + """Validate a developer catalog's extra groups, failing closed.""" + groups = document["developer_models"] + if not isinstance(groups, list) or not groups: + raise SystemExit("inputs developer_models must be a non-empty list") + allowed = supported_models.developer_boards() + names = {document["payload_name"], document["metadata_name"], document["engine_name"]} + boards = set(document["device_identifiers"]) + for group in groups: + if not isinstance(group, dict) or set(group) != DEVELOPER_GROUP_KEYS: + raise SystemExit( + "inputs developer_models entries must have exactly " + f"{', '.join(sorted(DEVELOPER_GROUP_KEYS))}" + ) + for key in ("payload_name", "metadata_name"): + name = group[key] + if not isinstance(name, str) or not name or "/" in name or name in {".", ".."}: + raise SystemExit(f"inputs developer_models {key} must be a plain file name: {name}") + if name in names: + raise SystemExit(f"inputs developer_models reuses the file name {name}") + names.add(name) + identifiers = group["device_identifiers"] + if not isinstance(identifiers, list) or not identifiers: + raise SystemExit("inputs developer_models device_identifiers must be a non-empty list") + for identifier in identifiers: + if identifier in boards: + raise SystemExit(f"inputs developer_models lists {identifier} twice") + if identifier not in allowed: + raise SystemExit( + f"{identifier} is not a developer board in scripts/supported-models.json" + ) + boards.add(identifier) + return groups + + +def load_inputs(path: Path, developer: bool = False) -> dict: """Read and fully validate the per-release inputs, failing closed.""" if not path.is_file() or path.is_symlink(): raise SystemExit(f"unsafe or missing inputs file: {path}") @@ -180,7 +221,7 @@ def load_inputs(path: Path) -> dict: missing = REQUIRED_INPUT_KEYS - keys if missing: raise SystemExit(f"inputs file is missing keys: {', '.join(sorted(missing))}") - unknown = keys - REQUIRED_INPUT_KEYS + unknown = keys - REQUIRED_INPUT_KEYS - ({"developer_models"} if developer else set()) if unknown: raise SystemExit(f"inputs file has unknown keys: {', '.join(sorted(unknown))}") @@ -262,6 +303,8 @@ def load_inputs(path: Path) -> dict: if not isinstance(download_url, str) or not download_url.startswith("https://"): raise SystemExit(f"inputs installer.download_url must be https: {download_url}") + if "developer_models" in document: + load_developer_models(document) return document @@ -295,6 +338,11 @@ def parse_arguments() -> argparse.Namespace: "--now", help="override the issue time as YYYY-MM-DDTHH:MM:SSZ (tests only)", ) + parser.add_argument( + "--developer", + action="store_true", + help="allow developer_models: for a developer build's sealed catalog only", + ) arguments = parser.parse_args() if not arguments.base_url.startswith("https://"): @@ -320,18 +368,44 @@ def issue_time(override: str | None) -> datetime.datetime: return parsed.replace(tzinfo=datetime.timezone.utc) -def main() -> None: - arguments = parse_arguments() - inputs = load_inputs(arguments.inputs) - assets = arguments.assets_dir +def model_group(inputs: dict, group: dict, assets: Path, base_url: str) -> tuple[list[dict], int]: + """The catalog models for one payload's boards, and its payload part count.""" engine = require_regular_file(assets / inputs["engine_name"]) - metadata = require_regular_file(assets / inputs["metadata_name"]) - payload = require_regular_file(assets / inputs["payload_name"]) + metadata = require_regular_file(assets / group["metadata_name"]) + payload = require_regular_file(assets / group["payload_name"]) - payload_artifact = artifact(payload, arguments.base_url) + payload_artifact = artifact(payload, base_url) parts = discover_parts(payload) if parts: - payload_artifact["parts"] = part_records(payload, parts, arguments.base_url) + payload_artifact["parts"] = part_records(payload, parts, base_url) + shared = { + "status": "enabled", + "asahiInstallerTag": inputs["asahi_installer_tag"], + "asahiInstallerRevision": inputs["asahi_installer_revision"], + "asahiInstallerDataRevision": inputs["asahi_installer_data_revision"], + "downstreamRevision": inputs["downstream_revision"], + "engineVersion": inputs["engine_version"], + "engineDigest": f"sha256:{digest(engine)}", + "metadataDigest": f"sha256:{digest(metadata)}", + "payloadDigest": f"sha256:{digest(payload)}", + "evidenceRevision": inputs["evidence_revision"], + "engineArtifact": artifact(engine, base_url), + "metadataArtifact": artifact(metadata, base_url), + "payloadArtifact": payload_artifact, + } + models = [ + {"deviceIdentifier": device_identifier, **shared} + for device_identifier in group["device_identifiers"] + ] + return models, len(parts) + + +def main() -> None: + arguments = parse_arguments() + inputs = load_inputs(arguments.inputs, developer=arguments.developer) + models, parts = model_group(inputs, inputs, arguments.assets_dir, arguments.base_url) + for group in inputs.get("developer_models", []): + models += model_group(inputs, group, arguments.assets_dir, arguments.base_url)[0] issued = issue_time(arguments.now) installer = inputs["installer"] @@ -345,25 +419,7 @@ def main() -> None: "latestVersion": installer["latest_version"], "downloadURL": installer["download_url"], }, - "models": [ - { - "deviceIdentifier": device_identifier, - "status": "enabled", - "asahiInstallerTag": inputs["asahi_installer_tag"], - "asahiInstallerRevision": inputs["asahi_installer_revision"], - "asahiInstallerDataRevision": inputs["asahi_installer_data_revision"], - "downstreamRevision": inputs["downstream_revision"], - "engineVersion": inputs["engine_version"], - "engineDigest": f"sha256:{digest(engine)}", - "metadataDigest": f"sha256:{digest(metadata)}", - "payloadDigest": f"sha256:{digest(payload)}", - "evidenceRevision": inputs["evidence_revision"], - "engineArtifact": artifact(engine, arguments.base_url), - "metadataArtifact": artifact(metadata, arguments.base_url), - "payloadArtifact": payload_artifact, - } - for device_identifier in inputs["device_identifiers"] - ], + "models": models, } arguments.output.parent.mkdir(parents=True, exist_ok=True) @@ -373,7 +429,7 @@ def main() -> None: print(f"sequence={catalog['sequence']}") print(f"evidence_revision={inputs['evidence_revision']}") print(f"models={len(catalog['models'])}") - print(f"payload_parts={len(parts)}") + print(f"payload_parts={parts}") if __name__ == "__main__": diff --git a/scripts/supported-models.json b/scripts/supported-models.json index b551bbd..f254074 100644 --- a/scripts/supported-models.json +++ b/scripts/supported-models.json @@ -39,5 +39,8 @@ "refused": { "apple,j575d": "Mac Studio (M3 Ultra, 2025): the pinned v0.9.2 engine has no j575dap device or T6032 chip entry, so it cannot inspect or plan this Mac", "apple,j614s": "MacBook Pro 14-inch (M4 Pro, 2024): M4 and later Macs are not supported" + }, + "developer": { + "apple,j700": "MacBook Neo (A18 Pro, T8140): boots its own J700 m1n1 and Stage 1 from its own image; developer builds only" } } diff --git a/scripts/supported_models.py b/scripts/supported_models.py index c490545..4be3cac 100755 --- a/scripts/supported_models.py +++ b/scripts/supported_models.py @@ -5,7 +5,9 @@ pinned engine can inspect. A catalog that offers any Mac offers all of them, so a release or preview can never ship a subset by accident. A channel with no Mac release yet (an empty catalog) is the only exception. `refused` names each -board that stays out and why. +board that stays out and why. `developer` names each board only a developer +build's sealed catalog may add, with its own image; channel catalogs never +carry one, so check-catalog rejects it like any unknown board. Usage: supported_models.py check-catalog FILE exit 1 unless FILE covers every Mac @@ -28,9 +30,17 @@ def load(path: Path = MANIFEST) -> tuple[list[str], dict[str, str]]: raise SystemExit(f"{path}: supported lists a Mac twice") if set(supported) & set(refused): raise SystemExit(f"{path}: a Mac is both supported and refused") + if set(document.get("developer", {})) & (set(supported) | set(refused)): + raise SystemExit(f"{path}: a developer board is also supported or refused") return supported, refused +def developer_boards(path: Path = MANIFEST) -> dict[str, str]: + """The boards only a developer build's sealed catalog may add, with why.""" + load(path) + return dict(json.loads(path.read_text()).get("developer", {})) + + def coverage_errors(identifiers: list[str]) -> list[str]: """Why IDENTIFIERS (the Macs a catalog enables) is not exactly the supported set.""" supported, refused = load() diff --git a/scripts/tests/test_make_unsigned_catalog.py b/scripts/tests/test_make_unsigned_catalog.py index 3da0049..e5286ea 100644 --- a/scripts/tests/test_make_unsigned_catalog.py +++ b/scripts/tests/test_make_unsigned_catalog.py @@ -38,7 +38,12 @@ def write_inputs(self, document: dict | None = None) -> Path: path.write_text(json.dumps(document or self.inputs, indent=2)) return path - def generate(self, document: dict | None = None, output: str = "catalog.json"): + def generate( + self, + document: dict | None = None, + output: str = "catalog.json", + developer: bool = False, + ): return subprocess.run( [ sys.executable, @@ -53,13 +58,30 @@ def generate(self, document: dict | None = None, output: str = "catalog.json"): str(self.directory / output), "--now", NOW, + *(["--developer"] if developer else []), ], capture_output=True, text=True, ) - def assertRejected(self, document: dict, fragment: str) -> None: - result = self.generate(document) + def with_neo(self) -> dict: + """The inputs plus a MacBook Neo group with its own split payload.""" + document = json.loads(json.dumps(self.inputs)) + neo = { + "payload_name": "neo-os-package.zip", + "metadata_name": "installer_data-neo.json", + "device_identifiers": ["apple,j700"], + } + (self.assets / neo["metadata_name"]).write_bytes(b"neo-metadata" * 8) + content = b"neo-payload" * 64 + (self.assets / neo["payload_name"]).write_bytes(content) + (self.assets / f"{neo['payload_name']}.part00").write_bytes(content[:300]) + (self.assets / f"{neo['payload_name']}.part01").write_bytes(content[300:]) + document["developer_models"] = [neo] + return document + + def assertRejected(self, document: dict, fragment: str, developer: bool = False) -> None: + result = self.generate(document, developer=developer) self.assertNotEqual(result.returncode, 0, result.stdout) self.assertIn(fragment, result.stderr) @@ -198,6 +220,7 @@ def test_the_manifest_is_every_m1_m2_and_m3_mac(self) -> None: self.assertEqual(len(manifest["supported"]), 34) self.assertTrue(set(M3_MACS) <= set(manifest["supported"])) self.assertEqual(set(manifest["refused"]), {"apple,j575d", "apple,j614s"}) + self.assertEqual(set(manifest["developer"]), {"apple,j700"}) for template in sorted(MANIFEST.parent.glob("release-inputs*.template.json")): identifiers = json.loads(template.read_text())["device_identifiers"] self.assertEqual( @@ -247,6 +270,62 @@ def test_the_committed_template_is_valid(self) -> None: catalog["models"][0]["payloadDigest"], f"sha256:{digest}" ) + def test_a_developer_catalog_gives_the_neo_its_own_payload(self) -> None: + result = self.generate(self.with_neo(), developer=True) + self.assertEqual(result.returncode, 0, result.stderr) + models = json.loads((self.directory / "catalog.json").read_text())["models"] + self.assertEqual( + [m["deviceIdentifier"] for m in models], + self.inputs["device_identifiers"] + ["apple,j700"], + ) + main, neo = models[0], models[-1] + self.assertEqual(main["payloadArtifact"]["fileName"], self.inputs["payload_name"]) + self.assertNotIn("parts", main["payloadArtifact"]) + self.assertEqual(neo["payloadArtifact"]["fileName"], "neo-os-package.zip") + self.assertEqual(neo["metadataArtifact"]["fileName"], "installer_data-neo.json") + self.assertEqual(len(neo["payloadArtifact"]["parts"]), 2) + self.assertEqual(neo["engineArtifact"], main["engineArtifact"]) + neo_digest = hashlib.sha256((self.assets / "neo-os-package.zip").read_bytes()).hexdigest() + self.assertEqual(neo["payloadDigest"], f"sha256:{neo_digest}") + + def test_developer_models_need_the_developer_flag(self) -> None: + self.assertRejected(self.with_neo(), "unknown keys") + + def test_a_developer_group_takes_only_developer_boards(self) -> None: + for identifier, fragment in ( + ("apple,j293", "lists apple,j293 twice"), + ("apple,j614s", "not a developer board"), + ("apple,j604", "not a developer board"), + ): + document = self.with_neo() + document["developer_models"][0]["device_identifiers"] = [identifier] + self.assertRejected(document, fragment, developer=True) + + def test_a_developer_group_reuses_no_file_name(self) -> None: + document = self.with_neo() + document["developer_models"][0]["metadata_name"] = self.inputs["metadata_name"] + self.assertRejected(document, "reuses the file name", developer=True) + + def test_the_main_group_still_covers_every_mac_in_a_developer_catalog(self) -> None: + document = self.with_neo() + document["device_identifiers"].remove("apple,j613") + self.assertRejected(document, "missing apple,j613", developer=True) + + def test_a_developer_catalog_never_passes_the_channel_check(self) -> None: + self.generate(self.with_neo(), developer=True) + check = subprocess.run( + [ + sys.executable, + str(SCRIPT.with_name("supported_models.py")), + "check-catalog", + str(self.directory / "catalog.json"), + ], + capture_output=True, + text=True, + ) + self.assertNotEqual(check.returncode, 0) + self.assertIn("apple,j700 is not in scripts/supported-models.json", check.stderr) + if __name__ == "__main__": unittest.main()