1.7.1 works around the Swift 6.4 defect that linked _swift_initBorrow
strongly (apple/swift-collections#739). A Release vphoned built with it
imports no _swift_initBorrow; StageBundle.sh keeps refusing one that does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bundle 2.2.5 (build 23) fixes iOS 18 guests that stopped at the Apple logo:
the camera hook typed the still sink's FourCC media type as an object, so
cameracaptured crashed on launch and SpringBoard waited on its flashlight
service forever (#541).
Launchpad 2.2.5 adds Install Skill to Host Setup with the vphone-guest-control
skill bundled inside the app, and the Core Bundle sheet no longer jumps when an
install starts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
swiftformat output only: wrapped single-line bodies, sorted imports, dropped
redundant self, throws and async, and plain numeric literals. No behavior
change; VPhoneCoreKitTests and the touched FirmwarePatcherTests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Host Setup gets an Install Skill button that opens a sheet with a copyable
prompt naming the skill folder inside the app, plus Show in Finder. The build
copies Skills/vphone-guest-control into Contents/Resources/Skills. The Core
Bundle sheet no longer shows an install-in-progress row, which made the layout
jump when it appeared.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The skill covers vphone-launchpad-cli, vphone.sock, vphoned methods, the
roothide and rootless layouts, the bootstrap and ssh flow, install problems
from the issue tracker, and where the research notes live.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
On iOS 18, -[BWStillImageSampleBufferSinkNode initWithInputMediaType:sinkID:]
takes the media type as a uint32_t ('vide'). The hook typed it as id, so ARC
retained 0x76696465 and cameracaptured crashed on every launch. SpringBoard
waits synchronously for cameracaptured's flashlight service at startup, so
the guest never left the Apple logo (#541).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bundle 2.2.4 (build 22) adds the tunnel network mode: the guest's traffic
leaves through ordinary connections opened by vphone-vm, so it follows a
VPN or proxy app on the Mac. vphone-vm and vphone-cli ignore SIGPIPE, so a
write to a closed socket no longer ends the VM. vphoned gains the three
accessibility client entitlements and an opt-in nested ui.tree that
follows the native parent-child links.
Launchpad 2.2.4 offers Tunnel in machine settings and New Machine, shows
it in the inspector, and leaves room for the Home button's glass bezel in
the title bar. Documents/Guides/networking.md describes the network modes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Machine settings and New Machine list Tunnel next to NAT, Bridged and
None, with a line saying what it does. The inspector showed a tunnel
machine's network as None; it now says Tunnel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The branch did not compile: the socket-pair initialiser delegated with
self.init without being a convenience initialiser, and three tests had
errors of their own (a shadowed binding, an argument out of order, and
self captured before the loopback server finished initialising).
Once built, four tests failed for reasons in the tests, not the stack:
two encoded the peer's MSS field where the SYN-ACK carries advertisedMSS,
one read its "before" count after a synchronous feed had already sent the
data it waited for, and one expected a frame to arrive with no known guest
MAC although the responder learns the MAC from that same frame.
On a real guest, vphone-vm died with signal 13 shortly after boot: a
forwarded TCP connection was written to after the server closed it. Every
tunnel socket now sets F_SETNOSIGPIPE, and vphone-vm and vphone-cli ignore
SIGPIPE for the whole process so a write to any vanished peer fails with
EPIPE instead of taking the guest down.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Documents/Downloads/README.md listing which 2.x Launchpad releases
ship a notarized zip, and the rule that Launchpad x.y works with any
VPhone.bundle x.y.z. Record the same rule in AGENTS.md: patch releases
within a series stay interchangeable both ways.
Slim README.md: move the package environment steps into
Documents/Guides/package-environment.md and the project structure into
Documents/README.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The titlebar accessory clips to its frame, and the Liquid Glass bezel
grows a little past the button when pressed, so its leading edge was cut.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nat and bridged both stop working when a VPN owns the host's default route. That is not a bug in either: vmnet masquerades out of a physical interface, so a guest behind a WireGuard or WARP tunnel is pinned to an interface the tunnel does not use, and its traffic leaves unencrypted or not at all. Nothing about the host's configuration can fix that.
This adds a third mode. The guest's NIC is carried by this process: frames arrive over a socket pair from a VZFileHandleNetworkDeviceAttachment, a small in-process network answers DHCP, ARP and ICMP and terminates the guest's TCP, and UDP is forwarded per flow. Egress leaves through ordinary host sockets, so it follows the host's routing table and therefore the tunnel, with no root, no interface, and no change to the host's networking.
The cost is that this is a TCP/IP stack. It has to be honest about what it implements, and the parts it gets wrong are not visible from a call that returns:
- The attachment socket must be non-blocking. The drain loop runs until recv reports EAGAIN on the same serial queue everything else shares, so on a blocking socket it parks in recv the moment the guest goes quiet -- which is exactly when the guest is waiting for a reply. The symptom was DNS queries leaving and no answers coming back.
- start() runs on that queue and calls into the forwarders it owns. Any of those hopping back onto it with sync deadlocks the queue against itself.
- The guest's receive window has to be honoured, or a download stalls halfway with both ends waiting.
- Window scaling has to be negotiated. Without option 3 in our SYN-ACK a guest must keep its window under 64 KiB however much buffer it has, and that ceiling over the round trip is all the connection can carry -- enough for pages, not for video.
- Unacknowledged data has to be sent again, and only the oldest segment of it: resending the whole unacknowledged range spends the connection's bandwidth on copies whenever the peer is merely slow to acknowledge, which is the common case.
- Segments must be cut to the guest's MSS, and a reply that still does not fit fragmented rather than dropped. QUIC rides at the MTU boundary, so an oversized datagram is discarded once per packet and looks like loss.
The tests are split so each level covers what it can. Frame-level tests drive the responder directly and need no VM, no socket and no privilege -- which is why they passed while every one of the bugs above sat in the code. They could not have caught any of the six that only appear under a real event loop, so those now have tests of their own: a socket pair, the DispatchSourceRead, the shared queue and both forwarders, with the guest's end reachable through an initialiser that adopts an existing pair rather than making one.
Egress is verified against a real guest: pages, downloads and video stream over the tunnel while the host's default route belongs to a VPN.
Known limit, not addressed here: in a guest VM the virtual GPU has no VP9/AV1 decode, so YouTube's web player never starts even though ordinary H.264 video (and the same page over nat) does. That is a property of the virtualised device, not of this mode.
Launchpad 2.2.3. Starting an install from Core Bundle, or continuing from
Host Setup, closes the current sheet before the next one opens, instead of
showing the next sheet's content in the closing one. The strings that still
showed in English are translated, unused catalog entries are gone, and
Check for Updates comes before Install Local Build. Bundle 2.2.3 (build 21)
has no changes beyond its version.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removes the 81 catalog entries Xcode marked stale and no Swift source
still spells, most left behind by renamed or reworded UI. Five stale
entries whose text still appears in source stay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bundle install step "Install with administrator access", the Core
Bundle footer naming the bundle store, the CFW install step and its
failure message, and the Standard and Experimental preset descriptions
had only English. Adds ja, ko, vi and zh-Hans, reusing the catalog's
existing terms for the helper, custom firmware and patches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS, sheet(item:onDismiss:) calls onDismiss in the same update that
clears the item, while the sheet is still attached. Setting the queued
panel there swapped its content into the closing sheet, so starting an
install from Core Bundle showed the install view in the old sheet, faded
it out and presented it again. Setting it on the next turn of the main
actor lets the old sheet close with its own content first. Host Setup's
Continue took the same path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The UDID override used to reach misagent and installd alone; Xcode, lockdown
and usbmuxd kept seeing the guest's own, so a paid team's profile could not
name the VM. The host reads the UDID in three places, and each is reachable
from userspace:
- lockdownd and remoted join vpIsMISFixTarget, so the spawn hooks insert
libmisfix into them. Only the MobileGestalt interpose acts there
(MISFixProcessOnlyNeedsIdentity keeps the MIS detours out). The hook now
matches the obfuscated key remoted asks with, re6Zb+zwFKJNlkQTUeT+/w.
- MGCopyAnswerWithError takes three arguments; the hook declared two and
crashed remoted, the first hooked caller of that spelling.
- vphoned sets the USB serial string, which is what usbmuxd names a device
by (vphoned_usb.m, com.apple.private.usbdevice.setdescription, with
AllowMultipleCreates), goes off the bus and back, and reapplies it at boot
once the USB device exists.
- udid.set/clear SIGKILL the hooked daemons (remoted ignores SIGTERM) and
always re-enumerate, which is also what relaunches remoted.
Measured on test-27.0: idevice_id, lockdown and the RSD handshake over both
transports report the override after udid.set and after a reboot, and the
guest's own after udid.clear.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Mac's CoreLocation fix has vertical accuracy -1 (no altitude). IcliKit
0.7.6 refused it with "vertical accuracy must be 0 or more metres", so
every location.set from Sync Host Location failed. 0.7.7 accepts -1 as an
unknown altitude.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
locationd never answers a client inside a generic bundle. From
VPhone.bundle/Contents/MacOS, vphone-vm's requestWhenInUseAuthorization
neither prompted nor changed the status, so Sync Host Location stayed at
Not Determined and sent nothing. Every .bundle layout behaved the same;
only an executable inside an .app was prompted.
vphone-vm now starts Contents/Helpers/VPhoneLocation.app (vphone-location,
an LSUIElement app) when sync is on. It asks for permission with its own
InfoPlist.strings, so the prompt text is localized, and writes one JSON
line per authorization change or fix. It exits when vphone-vm closes its
stdin. The bundle's own location keys and vphone-vm's location
entitlement are gone, ValidateBundle admits the helper and checks its
prompt translations, and the "Host Location Unavailable" alert is
localized.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bundle 2.2.1 (build 19). An app signed with a development certificate
launches on iOS 27.0: libmisfix now reaches SpringBoard, inserted at spawn
like installd's and misagent's, with no system binary modified for it.
Launchpad is unchanged: the app stays 2.2.0 and still accepts VPhone.bundle
2.2.0 or later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SpringBoard did carry SystemHook; what it lacked was libmisfix beside it.
Which libraries a process gets is decided in its parent, and launchd starts
SpringBoard itself, so SystemHook's list naming it was never consulted. A
probe reading KERN_PROCARGS2 showed DYLD_INSERT_LIBRARIES held SystemHook
alone in SpringBoard and both libraries in installd and misagent.
- vpIsMISFixTarget moves to InjectionEnvironment.h and the launchd hook
asks it too, inserting libmisfix only when the dylib exists.
- vpInsertHooks dropped the extra library when the environment already
named SystemHook; fixed, with make test-injection-environment.
- No guest binary carries a libmisfix load command. The three declarations
that added them are gone, cfw install puts each .bak back and removes
/mf, and inject-dylib --reclaim-source-version goes with its only caller.
- SystemHook's logs are world-writable: a root-created 0644 file silently
dropped every line from a mobile process, which is what made SpringBoard
look as though it never carried the hook.
Measured on test-26.4 and test-27.0, both recreated from local IPSWs: from
the first boot SpringBoard, installd and misagent carry both libraries, and
AirBuild installed through installd opens on both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A paid team's IPA installed on test-27.0 and was refused at launch with
0xE8008026: SpringBoard asks MIS itself, and it never carried the hook. The
spawn route SystemHook-vphone.c listed it under was never reached.
- system-springboard-cfw-launch_authorization links libmisfix into
SpringBoard with a weak load command, as installd and misagent are.
- SpringBoard's header has 16 spare bytes, so the command names a /mf
root alias and inject-dylib --reclaim-source-version drops
LC_SOURCE_VERSION to leave the re-signer room for its signature.
- MISFixInstallPolicy now runs in installd only.
Measured on test-27.0 after a cold boot: SpringBoard loads libmisfix, MIS
returns 0x0 for AirBuild, and it launches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`devicectl device install app` and `process launch` time out against the
guest. ideviceinstaller goes through installd the way Xcode does, which is
the path the install-gate hooks sit on; vphoned's apps.install re-signs and
places the bundle itself, so it says nothing about installd.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MICodeSigningVerifier carries allowAdhocSigning as a settable property and
installd never sets it, exactly like the MIS option. Forcing the getter makes
the real validation succeed and fill signingInfo, so the caller reads a
signing identifier instead of nil.
Forcing performValidationWithError: to return YES did not work and is gone:
the verifier had already bailed, so its caller refused on a nil identifier. A
refusal can be allowed through; an answer that was never computed cannot be
invented.
The class dump that found the property stays, but now runs only when a
selector this file expects has gone — the one moment it earns its length.
Measured on test-26.4: a codesign --sign - bundle with no certificate and no
profile installs through devicectl and launches, and so does a paid team's
dev-signed IPA.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An interpose never reached installd, so none of libmisfix's signature work
had ever run there. Replace it with a detour at the top of the callee, and
answer the two MobileInstallation refusals above it.
- MISFixDetour now takes an address, because no spelling of dlsym can
return one dyld has not interposed. It refuses a target shorter than the
four-word jump, which is what MISValidateSignatureAndCopyInfo is.
- MISFixProfileScope answers ProvisionsAllDevices for every profile, so the
embedded profile installs for real and MIS validates the app against it
with a genuine signer, entitlements and cdhash.
- MISFixInstallPolicy swizzles the embedded-profile install and the code
signing verifier, letting each refusal through after the real
implementation has run.
- MISFixNote appends to a file as well as the unified log, because a live
syslog tail has no lookback and every hook reports from a constructor.
Measured on test-26.4: a paid team's dev-signed IPA installs through
devicectl and launches. An ad-hoc signature is accepted by MIS and still
refused above it; Research/0_binary_patch_comparison.md says where.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shift-Command-3 saves, Shift-Command-4 copies, as on the Mac. Neither
collides: Shift-Command-C already opens the guest clipboard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vphoned saw nothing through Security.framework, so every row the browser
showed came from the keychain database as encrypted metadata, and the
only delete path was the test item's account and service pair.
Give vphoned the "*" keychain access group, which securityd special
cases into every group, and name items by class, account, service,
server and group. An identity with no attribute at all is refused so a
query cannot widen into a whole class. keychain.get and keychain.update
now belong to GuestKeychain alone; the file tool copies passed "genp"
straight to secClass(named:), which never accepted it.
Accessible rows list as hidden rather than protected: the value is one
read away, not out of reach. The strings file also names the trackpad
menu item, which had no entry at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Reword user-facing errors...` (090df08) and `Rewrite error messages and
labels...` (c27ea64) replaced the wording the Python bridge used with text
that says what failed and what to do next. Three tests still asserted the
old strings, so VPhoneRestoreTests has been failing ever since on five
expectations that describe a message nothing produces.
Assert the current messages and rename the tests, which no longer claim to
pin Python's wording. The type's doc comment made the same stale claim; it
now says the cases came from the bridge and the messages did not.
VPhoneRestoreTests: 67 tests in 6 suites pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on test-26.4. Copy-on-write on a shared-cache text page works: the
page splits out as its own region and comes back prot=7, max=7. The store
then faults anyway —
EXC_BAD_ACCESS (SIGBUS), UNKNOWN_0x32 at 0x1027543dc
__TEXT 102754000-102758000 [16K] rwx/rwx SM=COW /usr/lib/libmisfix.dylib
because Apple silicon enforces write-xor-execute below the VM permissions.
So RWX is not the safe request, it is the broken one, and the first run's
RW-without-X was only wrong because the page it dropped execute from was the
page the probe was executing. Both spellings crash, for opposite reasons,
and they look identical from outside; both are written down where the next
person will look.
The probe now drops execute for the write and the own-text warm-up is gone,
since that page is the one that must keep it. The executable-memory step
tries write-then-mprotect before RWX, same reason.
MISFixDetour is the fix this was all for: four words at the top of the
callee become an absolute jump, the displaced instructions move to a
trampoline, and every caller is redirected wherever it lives — which is the
whole point, since installd's callers are inside the cache. adr/adrp are
rewritten to materialise the same address and an unconditional b becomes an
absolute jump; bl, the conditional branches and literal loads are refused
rather than guessed at, because a wrong relocation is a corrupted daemon and
a refusal is a log line. Every emitted word was checked against the
assembler, not remembered.
The in-image trampoline arena is deliberately absent: filling it means
dropping execute from a page of __TEXT whose other occupant is the code
doing the dropping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Row 17's note claimed libmisfix "covers installation". It does not, and the
paragraph is superseded rather than edited, so the reasoning that was wrong
stays readable next to what replaced it.
The capture, the three corrections it forces — the signature was never the
problem, libmis resolves a UDID the interpose never sees, AllowAdHocSigning
has never taken effect in installd — and the three routes still open, with
why a detour is preferred over another libmis cache patch after #532.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SystemHook inserts this dylib into misagent and SpringBoard as well, and the
executable-memory step can end the process outright rather than return an
error. In SpringBoard that is a respring, repeating for as long as the flag
is on. installd is on-demand, so launchd starts it again for the next client
and a kill there costs one failed install.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewriting a callee needs two things the guest may or may not allow, and one
capture should settle both rather than costing a deploy each.
Writing the page is now measured twice: this dylib's own text first, which
is private, file-backed and already carries write in its maximum protection,
then the cache text the detour actually targets. The easy case failing would
mean nothing further is worth reading, and it is also a candidate home for
trampolines, so it is measured rather than assumed.
Executable memory is the other half, and the reason it comes last: a
trampoline is memory this process fills in and then jumps to, which is what
codesigning exists to stop, and a page the kernel has not blessed is a kill
rather than an error return. Both spellings are tried — PROT_EXEC straight
from mmap, then write plus mprotect, which is what a process without dynamic
codesigning has to do and the one likelier to survive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vphoned reports `setup_pending` in /v1/health and serves `setup.status`
and `setup.skip`. Device › Skip Setup Assistant… is enabled while the
guest reports it pending, and asks before it runs.
SpringBoard decides whether to run Setup once, when it starts. It does
while `SetupDone` in com.apple.purplebuddy is not true, and runs the flow
shown after a software update while `SetupVersion` is below
SetupAssistant.framework's BYBuddyIOSCurrentVersion (an int32, 11 on 26.4
and 27.0). Writing the keys while Setup is on screen does not dismiss it,
and killing Setup only makes SpringBoard start it again. So the skip
writes `SetupDone`, `SetupFinishedAllSteps` and `SetupVersion` through
cfprefsd for user mobile, then restarts SpringBoard. With every other key
in the domain removed, those three still reach the Home Screen, and no
later panes appear.
The experiments are in Research/Guest/setup_assistant_skip.md, including
how to send a guest back to Setup for testing. MCInstall's
SetCloudConfiguration, which pymobiledevice3, go-ios and cfgutil use,
works only on an erased device, so it does not fit here.
Verified on test-26.4 with this bundle: after deleting `SetupDone`,
Setup's hello screen appeared and setup.status reported pending true and
running true. setup.skip without force was refused. With force it
returned pending false, SpringBoard restarted and unlocked to the Home
Screen. Deleting `SetupVersion` then skipping also reached the Home
Screen. Afterwards the domain matched its state before the test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The measurement is in: an interpose does not reach a call made from one
shared-cache image to another. installd's whole install produced one line,
`MGCopyAnswer(BuildVersion) from installd` — the main executable's own call
and nothing else. No UniqueDeviceID query, although libmis resolved one; it
skipped every installed profile with 0xE8008012 and returned 0xE8008015.
No MISValidateSignatureAndCopyInfo line either, from a log that is now
unconditional. The signature was never the problem: `cdhash is trusted`.
So MISFixSignature.c's premise was wrong — installd does not decline to ask
MIS for ad-hoc acceptance, it never reaches this hook at all — and the
UniqueDeviceID key does not make an Xcode install succeed by itself. Both
files now say so, and say where the remaining fixes live: a shared-cache
patch, or a VM created with the ECID of an already-registered device, since
a modern UDID is <chip-id>-<ECID> and only the ECID is chosen.
Rewriting the callee instead of the call sites would reach every caller.
That costs a trampoline and arm64e relocation work, all of it wasted unless
the process can make a cache text page writable, so the probe asks that one
question behind its own flag and writes back the bytes already there.
Its first run got both halves wrong in a way worth keeping written down.
dyld applies interposing to dlsym, so RTLD_DEFAULT returned this dylib's own
replacement; and asking for write without execute on the page then executing
from it faulted immediately, which crash-looped installd until the flag went
off. The symbol is now resolved through a handle on libMobileGestalt, the
target is refused if it lands in this image, and the request is RWX.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first LogQueries run left the question half open. installd loaded the
hook and logged MGCopyAnswer(BuildVersion), then never logged a
UniqueDeviceID query — yet libmis plainly resolved one, because it skipped
every installed profile with 0xE8008012 before returning 0xE8008015. Two
incompatible readings fit that: installd's own code asked for the build
version and the frameworks ask past a __DATA,__interpose replacement, or the
interpose does reach cache-to-cache calls and libmis finds the UDID
somewhere other than MobileGestalt. They call for opposite fixes.
MISFixCallerImage resolves a return address to its image, so each line says
who asked. installd means the main executable and an interpose that stops at
the cache boundary; MobileInstallation or libmis means it crosses.
The validation log was ambiguous in its own way: it returned early when the
path would not convert, which is indistinguishable from never being called.
It now always logs, and says what the argument was instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cfw install` needs a prepared restore tree, and the restore tree is
deleted once a VM has first booted. So an existing machine could not be
moved onto a newer bundle at all: a new vphoned or a new guest dylib had
nowhere to come from, and the machine had to be rebuilt.
Replacing the files in a running guest over the API does not solve it
either, and not only for permissions: a daemon keeps the copy of a
library it mapped at launch, so writing /usr/lib/libmisfix.dylib under a
live installd changes nothing until installd restarts.
So the installer gains a mode. `.environmentOnly` mounts Disk.img
through the same clone-and-swap path as a full install and puts back
only the files a full install writes into the guest — vphoned, its
launch daemon, the guest dylibs, and the libmisfix defaults if the VM
has none. It runs no shared-cache or Mach-O patch, injects no load
command, does no cryptex or GPU work, skips the Preboot patches and the
snapshot rename, and leaves the recorded variant alone.
Two refusals keep it honest. It needs no restore tree, but it does need
`launchd.plist.bak`, which only the first `installVphoned` writes: with
nothing to put files back *over*, this would be laying down half an
install, so a VM that was never installed is refused. And a library the
guest does not already have is left out rather than added, because its
absence means the VM's plan never selected it.
The files it does write go through the same confined descriptors, modes
and owners as the full install, by calling the same `installVphoned` and
`installMISFixDefaults`. `installVphoned` rebuilds launchd.plist from
the backup rather than editing it, so the daemon cannot be injected
twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>