The two gates between Xcode and a vphone guest

HomePod 27 port (2026-10-23)

TempPod 27.2/24K120 (AudioAccessory5,1) reached three independent installation gates through Xcode 27.2 Run, after the DDI and UserManager lookup repairs:

  1. The public tvOS SDK drops device family 5 while generating UIDeviceFamily, even with TARGETED_DEVICE_FAMILY = 3,5. Its SupportedTargets.appletvos DeviceFamilies array defines only Apple TV (3).
  2. With the family repaired, a developer-signed app reaches an absent com.apple.misagent service and fails installing its embedded profile. HomePod ships neither the daemon nor its launch plist. The app instead uses ad-hoc signing through the user's researchsdk mechanism.
  3. MIS accepts the local signature with the existing signature options, but MobileInstallation then fails to extract a certificate signer identity (MIInstallerErrorDomain 13, LibMISErrorNumber -402620415, verifier line 422). Its existing allowAdhocSigning property is needed too.

system-installd-exp-homepod_adhoc ports these two signature adapters to a dedicated TVOS arm64e library loaded only into HomePod's installd. The VPHONE_MIS_SIGNATURE_ONLY build excludes the embedded-profile bypass; no identity, profile scope or general environment hooks are linked. Both native validation stages still produce their real signature metadata. The patch is 27-only, enabled by homepod-mini, and blocked by the iPhone and TV presets. Opt-out restores installd from .bak before removing /hm and the library.

The researchsdk generator at /Users/insidegui/Developer/_projects/researchsdk now adds HomePod's numeric family to both SDKSettings formats in the cloned tvOS SDK. It also selects Apple TV resource compilation because Interface Builder rejects a homepod storyboard target. HomePodApp uses the full research SDK path, TARGETED_DEVICE_FAMILY = 3,5, manual local signing and normal Xcode CodeSign. Its temporary metadata/signing build phase was removed. Xcode generates [3,5] itself, and codesign --verify --strict passes with no embedded profile. The app also needs the standard application-identifier entitlement ($(PRODUCT_BUNDLE_IDENTIFIER) for this local research signature), in addition to get-task-allow for LLDB. Without it, installd correctly returns error 63.

On TempPod with bundle 2.12.0-local.92401773, Xcode Run installed the app and launched PID 230 under LLDB. process status and bt 5 returned a stopped process and symbolicated system stack after the UIKit template asserted on its missing HomePod display configuration. The installd capture /tmp/temppod-homepod-signature-installer.json shows MIS result 0, native CdHash, SigningID, SignerType=1, ValidatedByProfile=false, IsNativeForPlatform=true, and “Install successful” in 0.05 seconds. A plain UIScene cannot substitute for UIWindowScene under the application's system role; UIKit reports that minimum-class constraint separately. These errors demonstrate working launch/debugger communication, not support for HomePod UI.

The full VPhone bundle passes admission and 77 selected catalogue, selection, version, declaration, plan, gate and guest-resolution tests pass. The persona native ABI/policy harness also passes. SDK-family insertion was applied twice to scratch copies of Apple's settings: both JSON/plist formats retain exactly two dictionary entries (3 and 5) with no duplicate; shell syntax and plist validation pass.

Live opt-out restored installd byte-identically to its original backup (SHA-256 6dd955e206ca3227ad28bda202718b7357c019e107ed79132f61a6a36d9b21cd), and removed /hm and the HomePod signature library. The persona patch was disabled in the same run and launchd also matched its original backup. TempPod booted successfully with both adapters disabled. The normal preset was then restored for the final installation/debugger check.

After restoring the preset (no overrides), Xcode installed and launched the headless Foundation version as PID 210. It uses a run-loop timer rather than UIApplicationMain, preserving the template UI source files for future work. LLDB stopped at researchHeartbeat, main.m:8; p heartbeatCount returned 2, thread step-over advanced it to 3, and po [NSProcessInfo processInfo].processName returned HomePodApp. The breakpoint was deleted and execution resumed. CoreDevice's process enumeration independently listed PID 210, and ddiServices reported the 27B48 cryptex compatible and usable after the reboot. Xcode's device picker identifies TempPod (Internal) with the tvOS 27.2 Research SDK. Template window scenes remain unsupported because HomePod has no display; the headless process is the validated research target. The process continued logging five-second heartbeats after resuming, without the earlier UIKit assertion. It is left running under Xcode's debugger, and the temporary MIS LogQueries configuration has been removed. Olive and the default bundle were not changed.

Original iPhone investigation

Measured 2026-09-30 on 05-mis-test (iPhone base 26.6.2 / 23G90, cloudOS 26.4 / 23E5207q, jb variant), booted, with mis_trust_auth applied.

xcrun devicectl device install app refuses two different things, in two different processes, and they need separate answers:

What you hand itRefused byError
An app not signed by an Apple leafinstalld0xE8008014 / 0xE800801C
A profile that does not name this devicemisagent0xE8008012

Everything behind both refusals already accepts unsigned code, so neither is a deep problem — each has one cause and one place to answer it.

Part one is the signature. Part two, further down, is the device identity.

What was measured

A throwaway app (wiki.qaq.vphone.signtest, one UIKit view controller) built with no team, no provisioning profile and no Apple certificate, offered to the guest three ways:

Signaturedevicectl device install app
none0xE800801C No code signature found.
ldid -S (the project's own shape: no CMS blob, no ad-hoc flag)0xE800801C
codesign --sign - (ad-hoc, flags=0x2(adhoc), sealed _CodeSignature)0xE8008014 The executable contains an invalid signature.

All three fail in the same frame:

Failed to verify code signature of
  /var/installd/Library/Caches/com.apple.mobile.installd.staging/temp.*/extracted/SignTest.app
  (MIInstallerErrorDomain error 13)
    FunctionName = +[MICodeSigningVerifier _validateSignatureAndCopyInfoForURL:withOptions:error:]
    LegacyErrorString = ApplicationVerificationFailed

which calls MISValidateSignatureAndCopyInfo in /usr/lib/libmis.dylib.

Everything past that gate already passes

The same app, pushed in through vphoned's apps.install — which re-signs in the container with vp_sign_app_for_install and registers the bundle itself, never touching installd or misagent — installs and runs:

apps.install → {"bundle_id":"wiki.qaq.vphone.signtest","containerized":true,…}
apps.launch  → {"frontmost_verified":true,"pid":591}
SpringBoard: … taskState: Running; visibility: Foreground

So the kernel side (amfi_trustcache, jb.post_validation, jb.amfi_execve, jb.cred_label_update_execve), lsd registration and SpringBoard's launch path all accept a bundle with no certificate and no profile. Only installd's install-time check does not.

That also means SpringBoard does not reject an ad-hoc signature at launch. The 0xE8008026 SpringBoard logs in the free-certificate case is a different gate — online authorization — and is what mis_trust_auth covers.

libmis takes an option for exactly this

MISValidateSignatureAndCopyInfo's options dictionary is read by string key. The keys are in libmis's cstring section and are, in cache order:

UnauthoritativeLaunch  AuthoritativeLaunch  ExpectedHash  AllowAdHocSigning
ValidateSignatureOnly  LogResourceErrors  UniversalFileOffset
UseSoftwareSigningCert  OnlineAuthorization  OnlineCheckType
RespectUppTrustAndAuthorization  HonorBlocklist  DetachedSignature
TrustCacheOnly  SkipProfileIdentifierPolicy  AllowLaunchWarnings
GetLocalLaunchWarningData  MainExecutablePath
OnlineAuthorizationOnAllMatchingProfiles

and the keys it returns in info:

ValidatedByProfile  ValidatedByUniversalProfile  ValidatedByLocalProfile
SignerType  SignerCertificate  SigningID  SigningTime  TeamID  CdHash
SignatureVersion  ProfileUUID  IsNativeForPlatform  LaunchWarningData

There is no SDK header for these; the symbols kMISValidationOption* are not in libmis's export trie, so searching for the symbol name finds nothing and searching for the string value finds everything.

A probe app — unsandboxed (com.apple.private.security.no-container, no-sandbox), so it could read the subject out of /private/var/tmp — called the function directly against the codesign --sign - bundle:

[no options]                                          -> 0xE8008014
[AllowAdHocSigning]                                   -> 0x0
    info keys: CdHash, Entitlements, IsNativeForPlatform, SignatureVersion,
               SignerType, SigningID, ValidatedByLocalProfile,
               ValidatedByProfile, ValidatedByUniversalProfile
    SignerType = 1   SigningID = wiki.qaq.vphone.signtest
    ValidatedByProfile = 0   CdHash = <20 bytes>
[ValidateSignatureOnly]                               -> 0xE8008014
[AllowAdHocSigning + ValidateSignatureOnly]           -> 0x0
[AllowAdHocSigning + SkipProfileIdentifierPolicy]     -> 0x0
[TrustCacheOnly]                                      -> 0xE8008014

Two things follow. The option alone is sufficient — installd simply never passes it. And MIS fills the whole info dictionary itself on success, so nothing has to be synthesised; a hook that faked a return code without CdHash and Entitlements would break installd further along.

Against the ldid-shaped signature the project's own signer writes, the same call returns 0xE800801C with or without the option. "No signature at all" is therefore out of reach and is not worth pursuing: everything downstream needs the cdhash only a signature carries, and Xcode's "Sign to Run Locally" (CODE_SIGN_IDENTITY = "-") supplies one for free.

Two details that cost time

The first argument is a path string, not a URL. Handing it an NSURL aborts the calling process inside libmis with -[NSURL length]: unrecognized selector sent to instance.

On arm64e the __DATA,__interpose section the compiler is asked for lands in __AUTH_CONST instead, because its pointers are signed. dyld honours it anyway — verified by loading the real libmisfix.dylib from a probe app's own bundle through a load command and calling the linked symbol:

linked call, no options -> 0x0        (0xE8008014 without the hook)

What was built

system-installd-cfw-adhoc_signature — VPhoneGuestComponents/MISFix/MISFix-vphone.c, built as /usr/lib/libmisfix.dylib, attached to /usr/libexec/installd by a LC_LOAD_WEAK_DYLIB that cfw install inserts. (Superseded 2026-09-30: the declaration is gone, and the spawn hooks insert libmisfix into installd, misagent and SpringBoard — see "One route for libmisfix" in Research/0_binary_patch_comparison.md.) It interposes MISValidateSignatureAndCopyInfo and MISValidateSignatureAndCopyInfoWithProgress, adds AllowAdHocSigning to the caller's options, and calls through. Nothing in the dyld shared cache is touched — deliberately, since issue #532 is mis_trust_auth writing a cache code page and leaving a 27.0 guest unable to boot.

Still open

MICodeSigningVerifier lives in MobileInstallation, a shared-cache dylib, so its call into libmis is cache-internal. dyld's interposition is documented to patch the cache's own uses of an interposed symbol, and the probe above confirms the section shape is honoured on arm64e, but the cache-internal case itself has not been observed yet — that needs the hook running inside installd on a guest, which is the 27.0 integration test.

Only installd carries the hook. SpringBoard was left alone on purpose: the evidence above says it does not reject an ad-hoc signature, and a mistake there is a black screen rather than a failed install.


Part two: the device the profile names

A real, paid-team app fails a step later. AirBuild-Debug-main-da1b84d8.ipa, signed Apple Development: Lakr Aream (ZL3L65D2SL), team QDJ93ZUQ9B, whose embedded profile provisions eight real devices and no VM:

Failed to install embedded profile for plus.yellow.AirBuild : 0xE8008012
  (This provisioning profile cannot be installed on this device.)
  -[MIInstallableBundle _installEmbeddedProfilesWithError:]

This one is correct behaviour and always has been: the VM's UDID is in nobody's ProvisionedDevices. Xcode papers over it for a free personal team by registering whatever device is plugged in — which is why iOS Team Provisioning Profile: wiki.qaq.vphone.test (team M4Z5DVY94F) lists both 0000FE01-04A9F39591FF643D and 0000FE01-5F651C7ECC040381. For a paid team there is no auto-registration, and a fresh vm create means doing it again by hand.

Which process decides, and what it asks

installd reports the error but does not make the decision; misagent does. Its imports and strings give the whole algorithm away:

$ nm -u misagent | grep '^_MIS'
_MISProfileCreateDataRepresentation  _MISProfileCreateWithData
_MISProfileCreateWithFile            _MISProfileGetValue
_MISProfileIsDEREncoded              _MISProfileValidateSignature

No MISProvisioningProfileIncludesDevice — misagent reads ProvisionedDevices itself and does its own comparison. Its strings, in the order the code uses them:

amfi_emulate_device_udid   "Using emulated device UDID: %{public}@"
"got NULL when querying UDID"   "got non-string when querying UDID"
"could not get device UDID"   deviceUDID   UniqueDeviceID
ProvisionedDevices   ProvisionsAllDevices

So it first looks for an emulated UDID in the kernel's codesigning configuration, then falls back. Measured in the guest:

sysctlbyname("security.codesigning.config") -> 0, size 4, bytes 0x000000CC
MGCopyAnswer(UniqueDeviceID)     = 0000FE01-5F651C7ECC040381
MGCopyAnswer(UniqueDeviceIDData) = <25 bytes, the same string>
MGCopyAnswer(SerialNumber)       = vphone-1337
MGCopyAnswer(UniqueChipID)       = 6873931737165202305   (0x5F651C7ECC040381)

security.codesigning.config is a four-byte flags word, not a dictionary — it cannot carry a UDID, and amfi_emulate_device_udid appears in neither the vphone600 kernelcache nor TXM. That path is dead on this board, so the MobileGestalt fallback is what always runs.

MGCopyAnswer is exported from libMobileGestalt, and misagent imports it across an image boundary, so it is interposable.

Where the guest's own UDID comes from

Worth writing down, because it rules out the alternatives. TXM builds it, before the kernel runs, out of the device tree:

/chosen/chip-id            CPID        (0x0000FE01, fixed by the virtual SoC)
/chosen/unique-chip-id     ECID
/product/udid-version      picks the v2 or v3 format

TXM's strings: successfully queried the UniqueDeviceIDv2 from the device tree, unsupported UniqueDeviceID version: %u, unable to find udid-version property in /product.

The host predicts the same string from the same inputs — VPhoneVirtualMachine.resolveDeviceIdentity reads _ECID off the VZMacMachineIdentifier and formats "%08X-%016llX" against VPhoneHardware.udidChipID = 0xFE01, then writes udid-prediction.txt. The prediction is reliable because both sides compute it the same way.

Two consequences. data_ark.plist does not hold it — a live guest's copy has eleven keys and UniqueDeviceID is not among them — so no daemon can change it by writing a file. And reproducing a real iPhone's UDID is impossible even in principle: chip-id is fixed at 0x0000FE01, while a real device's is something like 0x00008150, and unique-chip-id is the ECID the guest's SHSH blob is issued against, so changing it means restoring the VM again.

What was built

system-misagent-cfw-device_identity — the same libmisfix.dylib, attached to /usr/libexec/misagent (now inserted at spawn, like installd's; the declaration is gone), interposing MGCopyAnswer and MGCopyAnswerWithError and answering UniqueDeviceID with the value in libmisfix.plist. Set it to a device the team has already registered and that team's profiles install here, with no portal round trip and nothing to redo after a rebuild. Absent or empty, the hook is inert.

The settings file is read from /var/db/vphone/misfix.plist first and /usr/lib/libmisfix.plist second, cached against mtime and size so an edit takes effect on the next query with nothing restarted. The data-volume path is the one a running guest can be reconfigured through; the /usr/lib copy is what cfw install ships, and it is only written when the guest has none, so re-running the installer never puts an empty file over a UDID someone set.

The VM window sets it from Device › Set UDID… and Reset UDID, through vphoned's udid.set and udid.clear (Research/vphoned_http_api.md). vphoned writes the data-volume file, reads it back and restarts the hooked daemons.

The host sees it too (2026-09-30)

This section used to say the host keeps seeing the guest's own UDID, and that making the two agree needed a re-restore. It needs neither. The host learns the UDID in three places, all of them in userspace or reachable from it:

WhereWho answersHow the override gets there
usbmuxd, idevice_idthe USB serial stringIOUSBDeviceFamily takes a new description from a process holding com.apple.private.usbdevice.setdescription; vphoned sets the serial (with AllowMultipleCreates, or the controller refuses with 0xe00002e2 once the device exists) and goes off the bus and back
lockdown GetValue UniqueDeviceIDlockdowndlibmisfix is inserted into it; it calls MGCopyAnswer(UniqueDeviceID) from its main executable
RSD handshake (CoreDevice, Xcode)remotedlibmisfix is inserted into it; it calls MGCopyAnswerWithError with the obfuscated key re6Zb+zwFKJNlkQTUeT+/w

The kernel builds the USB serial itself from /chosen chip-id and unique-chip-id (%08X%016llX); IOPlatformSerialNumber is vphone-1337 and plays no part. vphoned starts before the USB device exists, when the controller has no description to change, so it retries in the background until it can.

Measured on test-27.0 with 00008150-00112233445566AA: idevice_id, ideviceinfo -k UniqueDeviceID and remotectl show (both the virtualmachine and the ncm transports) all report the override, after udid.set and after a reboot; udid.clear puts all three back. ideviceinstaller list works under the new identity.

Two things turned up on the way. MGCopyAnswerWithError takes three arguments (question, options, error), and the hook had declared two; misagent never calls that spelling, remoted does, and it crashed at 0xe until the prototype was fixed. And remoted runs with EnableTransactions, so SIGTERM does not stop it, and it is launched by the NCM link coming up, not on demand; vphoned sends SIGKILL and always re-enumerates.

What stays the guest's own is TXM's and the kernel's view, and CoreDevice's record of a pairing made under the old UDID. A new UDID is a new device to the host, so the guest asks to trust the computer again; it keeps one lockdown pair record per host, so switching back asks again too. Pairing with devicectl itself is not covered here: it times out against vphone guests for reasons unrelated to the UDID.