pair-e2e: run the Mac app under the rig's own keychain

Merged
TE
8d ago
+207-21

Root cause

The pair-e2e Mac pairing page stayed "preparing" with no code on a fleet GUI Mac (steps 59c5f9eb8bfb8976560ed476, c9c9b027f31d54119d869a81, e2b11b0cff9e8634d7530c87). The evidence from #27345 (step e2b11b0c) names the call that prompts.

IPhoneHostService.reconcile()  IPhoneHostService.swift:419
  MobileHostMacIdentityStore.loadOrCreate()  MobileHostMacIdentity.swift:76
    MobileHostMacIdentityStore.keychainAdd(_:)  MobileHostMacIdentity.swift:106
      SecItemAdd -> SecItemAdd_osx -> SecKeychainItemCreateFromContent
        StorageManager::defaultKeychainUI -> StorageManager::makeLoginAuthUI
          AuthorizationCopyRights  (blocked for the whole sample)
  • authd asks for system.keychain.create.loginkc, and SecurityAgent runs loginKC:queryCreate.
  • security default-keychain -d user prints "A default keychain could not be found": the worker's console account has no login keychain.
  • So the app's first write to the file keychain opens the "create a login keychain" dialog. The store's interactionNotAllowed context doesn't cover that dialog, and nobody answers it.
  • hostd never starts. DeviceHub.loadIdentity (DeviceKeychain.read) waits behind the dialog on the StorageManager lock.

Change (rig only)

scripts/ci/cmux_next_pair_e2e.py now runs the Mac app under the rig's own file keychain (RigKeychain):

  • Before launch: it records the user domain's search list and default keychain in a 0600 file, after reading both successfully. It refuses to run, before changing anything, if either read fails. It then creates rig.keychain-db next to the tagged app with a random password (passed in argv, never in a file or log), puts it first in the search list, and makes it the default.
  • In the rig's finally and in --forget-tag (the sim script's EXIT, for a run cut short):
    • It puts the list and the default back. An unset default is unset again, and a default someone else set during the run is left alone.
    • It reads both back.
    • Only after they read back does it delete this run's items from the rig's keychain, then the keychain, then the record. A failed restore keeps the record so the next call retries.
  • It never reads, changes, or deletes an item in the account's own keychains. forget_keychain now deletes only inside the rig's keychain, and its security calls have timeouts.

No Swift changes, and the release path is untouched.

Later fixes on this branch (found by the fleet runs)

  • The step's own HOME. The fleet worker runs a step with HOME=<job>/home, which is empty. The Security framework keeps the user's keychain preferences under $HOME/Library/Preferences, and without that folder security ... -s exits 0 and keeps nothing (step 8703f0e6bd2dcb9d98a19319). That empty HOME is also why the app had no default keychain in the first place. start() now creates that folder and reads back what it set. So in a step, the list and default the rig changes belong to the step's HOME, not the console account's.
  • Restore. security ... -s with no keychain argument is a no-op, so an empty list and an unset default are restored by delete-keychain instead.
  • The pairing code reaches the phone. The phone first dials at launch, before it has the code, so the rig restarts the app with the code. simctl openurl shows an "Open in cmux?" alert that nothing answers (steps d7afa7349ed59cb5603acf70, c8c0dec1495fb4e584ad2b9e). On a ref whose DEBUG phone app takes CMUX_MOBILE_PAIR_URL (#27445, held for the Swift hold), the code goes in the launch environment and never in argv or a log. Other refs keep openurl.
  • The rig's Mac name. The rig's Mac is listed as 0 e2e rig Mac (DEBUG name override), so it sorts first in the phone's Settings > Devices list and no host name appears in the captures.
  • No token in error output. A failed openurl or launch with the code raises without the link.

Proof: on a branch that combines this PR with #27445 (p0m/pair-e2e-proof), step 423a0baebf9379c6d48ee0ec on a fleet GUI Mac logged phone 70d27bfc paired from this Mac's pairing code, 2 s after launch, with no Allow prompt and no seed.

Review: Codex gpt-6-astra (high, fast) reviewed every head and said SHIP at 7dcf0e2d, 63fc30fe, 2b98b3f5 and 33829caa, after fixing each BLOCK. The last commit (4981c6cb, the Mac name) adds one DEBUG environment variable.

Risk: low. It's a CI rig on fleet CI Macs only (fleet-only guard). While a pair-e2e step runs, the console account's default keychain is the rig's.

Agent session: agent session

šŸ¤– Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Fixes the pair-e2e Mac pairing page staying "preparing" by running the Mac app under the rig's own file keychain. A fleet step runs with its own empty HOME, so the console account has no keychain preferences under $HOME/Library/Preferences; without a default keychain, the app's first keychain write prompts a "create login keychain" dialog that nobody answers, and hostd never starts.

Also fixes pairing when the page shows a code but the phone never presents it: the phone's dial happened at launch, before the app had the code, so its auth waited on the Mac's Allow and the later-opened link was never presented. The rig now ends the app before opening the link, so the Mac answers the earlier auth with the code; on a ref whose DEBUG phone app takes a pairing code at launch, the code is passed in the app's launch environment instead of via openurl (which makes the simulator ask "Open in cmux?" that nothing answers). The openurl call and the launch with the code are bounded and raise without exposing the link's token, the code, or argv.

Rig keychain

  • Runs only under a step's own HOME; an account's HOME is refused, so two runs can never record or restore each other's keychains.
  • Creates rig.keychain-db next to the tagged app, plus ~/Library/Preferences for the step HOME, puts the keychain first in the user search list and makes it the default, then restores both after the run.
  • Restores the account's list and default first in every cleanup path; only a recorded non-empty list or set default is restored, and empty list and unset default come back via delete-keychain; the record is kept until the restore reads back correctly.
  • Names the rig's Mac "0 e2e rig Mac" via a DEBUG override so it sorts first in the phone's Devices list, keeping the host's name out of captures.