UNIFIED INSTALL CONTRACT
Status: first draft, not agreed. Written to make issue #6's proposal concrete enough to react to, not to settle it. Corrects one oversimplification in the RFC text along the way (see "What wootc actually does" below).
What wootc actually doesβ
The RFC describes wootc's contract as a single vault.json matching
install-config.json's shape. In the real code
(app/vault_windows.go, app/installer_windows.go) it's split across
three channels, not one file:
vault.json(0o600, ACL-restricted to SYSTEM/Administrators) β onlyusername,hostname,image,password_hash. The password is hashed withsha512_crypt($6$...) before the file ever touches disk; plaintext never lands anywhere.- Bootloader-entry kernel cmdline args β
wootc.image=,wootc.hostname=(duplicated from vault.json β readable before NTFS is even mounted),wootc.bootloader=,wootc.luks=<encryption-type>. - fisherman's
recipe.json, assembled by the deployer script from (1) and (2) at runtime, not written by the Windows app directly.
This split exists because of a real Windows constraint: the deployer
initramfs needs some config (image ref, LUKS type) available from
/proc/cmdline before it has mounted anything, while richer config
(username, password hash) can wait until the NTFS volume holding
vault.json is mounted.
Why Asahi doesn't need the splitβ
install-config.json already lives on the ESP (per DESIGN.md), and the
bootsahi bootstrap mounts the ESP as one of its first actions regardless
(it needs <ESP>/m1n1/boot.bin and the bootstrap root itself lives there
too) β there's no "before any mount" phase analogous to wootc's Windows
cmdline trick where a single JSON file doesn't already work. Recommend
keeping Asahi's contract as the single-file install-config.json already
specified in components/bootsahi-agent/install-config.schema.json β
simpler, and wootc's split is solving a problem Asahi doesn't have, not a
pattern worth importing for its own sake.
What to actually converge onβ
Not the file split β the field shapes and security conventions, since
fisherman's recipe.json is the true shared contract underneath both:
| Concern | wootc | Asahi (current) | Converge? |
|---|---|---|---|
| Password | $6$ hash, hashed client-side, password_hash field | plaintext password field | Yes β see below |
| Image ref | image | targetImgref | No β Asahi's name is clearer (wootc's image is also the current value in other structs); not worth a rename fight |
| LUKS type | Encryption string (none/tpm2-luks/...) on InstallConfig, forwarded as wootc.luks= cmdline | encryption.type object | Already aligned in spirit; Asahi's object form is finer-grained (carries passphrase alongside type) and should stay |
| Hostname | hostname | hostname | Already aligned |
Concrete action taken in this PR: install-config.schema.json's
user.password field now documents the $6$-hash convention explicitly
(fisherman's chpasswd step β projectbluefin/fisherman only, see below β
already auto-detects a $-prefixed value and passes chpasswd -e; a plain
string is also accepted but means the password sat in the file in the clear
until install completed). The macOS app should hash client-side the same
way vault_windows.go does, before install-config.json is ever written to
the ESP.
A real, non-hypothetical blocker found while writing thisβ
Resolved β this section is kept for the record. components/bootsahi-agent
was built against github.com/tuna-os/fisherman while wootc vendored
github.com/projectbluefin/fisherman, which was 14 commits ahead and had
fixes tuna-os/fisherman lacked entirely. The forks are now synced
(tuna-os/fisherman#59), which incidentally turned tuna-os/fisherman's own CI
from red to green β it had been failing on exactly those bugs. The pin here
now points at tuna-os/fisherman, which additionally carries the
customMounts validation (#58) and TPM2 first-boot enrolment that
projectbluefin does not. What was missing:
MountTypeβ an explicitmount -t <fstype>for the freshly-formatted root. Without it, the deployer initramfs (no libblkid probe path) can attempt an xfs root as ext4 and fail outright. The Asahi dracut/initramfs likely has the same no-probe property (unverified β needs an aarch64 re-check, tracked in the hardware testing checklist).β superseded. The story evolved:chroot <target> useraddinstead ofuseradd --root <target>5025d4dmoved tochrootbecause--rootdrags in the host's PAM/SELinux stack, thene2a6499reversed that for composefs-native (dakota exit 127) back to--root, andf94a716/d12b6cbrefined it further. Classic ostree and composefs-native need different handling, and fisherman detects which at runtime. Any statement of the form "use chroot, not --root" β including earlier revisions of this document β quotes one step of a sequence as though it were the conclusion.
bootsahi-agent's README and the hardware testing checklist have been
updated to point at projectbluefin/fisherman accordingly. This should be
fixed before any real-disk testing, not after β these are exactly the kind
of failures that only show up once you're not on a mocked stdin.
The handoff: how install-config.json reaches the ESPβ
(Added after reading fisherman and asahi-installer source. This is testing-checklist step 4, the item blocking every real-disk step. It also corrects the section above β see "Correcting my own Β§1 recommendation".)
The questionβ
install-config.json carries rootPartition and espPartition. The macOS
app cannot know those until the backend has partitioned the disk, and the
backend partitions the disk during the install run. So: who writes the file,
where, when, and what identifies the partitions?
Option "backend hands device nodes back" is not merely awkward β it is impossibleβ
The app runs on macOS, where the partitions it just created are named
disk0s5. The agent runs on Linux, where the same partition is
nvme0n1p5. A device node learned on macOS is meaningless to the agent,
so no amount of plumbing device nodes back to the app produces a usable
value. This isn't a preference between two workable designs; it eliminates
one of them.
Both channels this needs already exist upstreamβ
- A post-partition write hook.
installer_data.json's EFI partition entry already setscopy_installer_data: true, which makesosinstall.py:169register<ESP>/asahi/as a target, andmain.py:596callscollect_installer_data()over those targets β afterosins.install()has created and mounted the partitions. Files are copied fromstub.py'scopy_idatalist, andstub_info.jsonandinstaller.logare written there too. Addinginstall-config.jsonis an append to that list, not a new mechanism. - A partition identifier that crosses OS boundaries. Every
diskutil.pypartition object carries its GPT UUID (uuid=partinfo["DiskUUID"],diskutil.py:134), and asahi-installer already threads the ESP's into the boot chain:chosen.asahi,efi-system-partition=<uuid>andchainload=<uuid>;<next_object>(osinstall.py:189-192). It even prints it to the user as "EFI PARTUUID" (main.py:731). PARTUUID is already this stack's identity currency β stable across macOS/Linux and immune to partition renumbering.
Proposed contractβ
Split by who knows what, and when:
| Channel | Written by | When | Contents |
|---|---|---|---|
<ESP>/asahi/install-config.json | macOS app β backend's copy_idata | after partitioning, by the existing collect_installer_data() hook | intent only: targetImgref, user (with $6$ hash), hostname, filesystem, encryption, wifi, cosign*, sshEnabled |
<ESP>/asahi/stub_info.json (existing file, extra keys) | backend | same hook | facts only the backend knows: every created partition's PARTUUID plus its declared role (esp/bootstrap/target) |
Implemented. The backend records partitions[] after osins.install();
the agent resolves role -> PARTUUID -> /dev/disk/by-partuuid/<uuid> and then
refuses unless it can prove the target is safe: not the active root, not
mounted, and on the same parent disk as the ESP. Zero or multiple matches for a
role are refused rather than disambiguated β an ambiguous identity is not an
identity. Roles are declared in the payload template rather than inferred from
a display name or an ordinal, and test-payload.sh requires them, so a payload
cannot silently ship without them and degrade the agent to the dev/test path.
Credential lifetime on the ESP (the channel is not a safe resting place)β
The channel table above says where the file goes; it also has to say how long it lives, because the ESP is a bad place to keep secrets:
- It is vfat β no permission bits. Nothing can be
0o600there, unlike wootc'svault.json, which is0o600and ACL-restricted to SYSTEM/Administrators on NTFS. - It is not tmpfs (unlike the agent's
RUN_DIR), and it stays mounted at/boot/efion the installed system indefinitely. - The password travels as a
$6$hash, which is the point of that convention β but the LUKS passphrase and Wi-Fi PSK cannot be hashed, because they have to be usable. They are necessarily plaintext-equivalent.
Leaving the file in place would publish the disk-encryption passphrase, in
the clear and world-readable, on the machine we just encrypted. So the
contract is: the agent removes install-config.json on a successful
install, in the same place it already shreds recipe.json β and
deliberately preserves it on failure, since the interactive fisherman UI
it falls back to has nothing to retry from otherwise. Both directions are
asserted by test-agent.sh.
(shred is best-effort and largely theatre on vfat over wear-levelled
flash; removal is the part that carries the weight. Worth noting rather than
pretending otherwise.)
The app writes no device fields at allβ
So: the app writes no device fields at all. rootPartition and
espPartition stop being app-supplied inputs and become values the agent
resolves at runtime from /dev/disk/by-partuuid/<uuid>. They should leave
required in the schema and be retained only as an explicit dev/test
override (which is exactly how test-agent.sh uses them today).
Correcting my own Β§1 recommendationβ
The section above concluded "Asahi doesn't need wootc's split" because Asahi has no pre-mount phase forcing config onto the kernel cmdline. That reasoning was right about the file and wrong about the boundary.
wootc's split is not primarily an early-mount hack β it is a separation of
knowledge: the host app writes what it knows before touching the disk, and
the runtime resolves what only the runtime can know. Asahi needs that same
boundary for exactly the reason wootc needed it, even though Asahi can keep
one file on one channel. Recommendation stands (single JSON file, converge
on field shapes and the $6$ convention); the correction is that the
device-identity fields belong on the runtime side of the line, not in the
app's file.
The blocking constraint: fisherman formats /β
Reading tuna-os/fisherman turned up something that has to be settled
before any of the above can be implemented. disk.ApplyCustomLayout()
(internal/disk/custom.go:61) runs mkfs on every custom mount whose
fstype isn't unformatted/"" β including /. Three things currently
believed simultaneously cannot all be true:
DESIGN.md: a ~1.5 GB bootstrap root boots and runs the agent.scripts/make-payload.sh: the payload declares exactly two partitions βEFIandRoot(expand: true). One Linux partition.- fisherman: formats the partition it installs
/onto.
You cannot mkfs the filesystem you are running from. And this is not just a layout tidiness question β LUKS forces it. Encrypting the root means reformatting it as a LUKS container, which is impossible in place, so encryption cannot work at all under the current single-partition layout, whatever else changes.
Options, for James to pick:
- A β three partitions. ESP + a small fixed-size bootstrap root + the
target root (
expand: true). The agent installs into the target root and the bootstrap partition is reclaimed afterward (or kept deliberately as a rescue system). This is the direct wootc analog: bootstrap root = Phase 2, target root = Phase 3 native-disk graduation. Needs only amake-payload.shchange, and the agent resolves "the Linux partition that isn't the one I'm running from" β or better, reads the target's PARTUUID from the backend per the table above. - B β bootstrap runs from RAM. Boot the bootstrap as a squashfs/initramfs live root, leaving the single Linux partition free to be formatted. Cleaner on disk and keeps the two-partition layout, but needs a live-root dracut path built new on this side.
C β(install in place, no reformat). Discarded: it bypasses fisherman's formatting entirely, and so gives up the shared-installer-brain premise that RFC Β§1 exists to serve β and still cannot do LUKS.bootc install to-existing-root
A vs B is a real trade (one payload script change vs. a cleaner disk layout), and everything downstream of testing-checklist step 4 waits on it.
Decided: option A β see ADR 0001. The payload now emits three partitions.
The generated recipe is still not correct β build_recipe emits
rootPartition verbatim, and nothing resolves it to the installer-created
target yet (that is #22). But since ADR 0001 the disk carries two Linux
partitions, which made a wrong value plausible rather than obviously bogus,
so the agent now positively refuses the catastrophic ones: a rootPartition
or espPartition that resolves to the device backing /, or the two being
the same device. Compared by major:minor via /proc/self/mountinfo, so
/dev/nvme0n1p5 and /dev/disk/by-partuuid/... aren't mistaken for different
devices.
The original hazard, for the record: build_recipe
emits the root mount as { partition: $c.rootPartition, target: "/", fstype: $c.filesystem }, and under the current two-partition payload the only Linux
partition is the one the agent is running from. fisherman would mkfs it
mid-install. That is not deferred cleanup; it is a live hazard, and it is why
the root mount is left untouched here rather than "fixed" to something
plausible. The correct value is a function of which layout wins.
Two live bugs found while writing thisβ
Both in the recipe bootsahi-agent generates, both invisible to the
selftest as it stood, both fixed in the same PR as this document:
- The ESP mount specified
fstype: "vfat", which fisherman does not accept.recipe.Validate()doesn't checkcustomMountsfstypes (internal/recipe/recipe.go:148-166), so it passes validation and then fatals insideformatPartition()β whose switch knowsfat32, notvfat. This recipe has never been valid; it would have died at fisherman step 1 on the first real run. - The obvious fix is the dangerous one. Changing it to
fat32makesApplyCustomLayoutrunmkfs.fat -F32on an ESP that by then holdsm1n1/boot.bin, the bootloader,stub_info.json, andvendorfw/β the Apple firmware extracted on-device, which is not redistributable and therefore cannot be restored from anywhere. That is a DFU-restore-grade mistake. The correct value isunformatted, which skips only themkfs; the mount and theefiPartbookkeeping fisherman needs for the boot entry both still happen (custom.go:68-86).
The selftest now asserts every customMounts fstype is in
formatPartition's accepted set, and separately that the ESP's is a
skip-format token. Both assertions were verified to fire against the old
vfat value. The gap that let this through is worth naming: the previous
shape checks grepped that a /boot/efi mount existed, never what it
would do.
Also confirms RFC Β§5's MountType landmine, with a line numberβ
custom.go:85 is runner.Run("mount", s.Partition, hostTarget) β no
-t. That is precisely the missing-explicit-type bug Β§5 describes, live in
tuna-os/fisherman today, and already fixed in
projectbluefin/fisherman. It strengthens the existing recommendation to
build from the projectbluefin fork.
One correction to issue #6 Β§5β
The RFC lists the clevis/dracut-omit landmine under "already fixed for you"
in the shared fisherman. It isn't β it lives in wootc's own deployer
script (payload/deployer/deploy.sh's DRACUT_OMIT handling), a
post-install dracut regen step wootc runs that bootsahi-agent doesn't
currently have an equivalent of. Not urgent today (D1 has no dracut-regen
step yet), but worth a comment marker if/when Asahi's agent ever grows one.