Settings > Devices: each paired phone gets its own acpmux credential and the Full access switch (off by default)

TE
12d ago
+642-25

Follows #25078 (merged, squash 5251e6b7ad4), which gave a paired phone its own acpmux credential (role phone) for the per-device Full-access setting of #25038. #25069 added the Settings > Devices switch for paired machines. This PR gives every paired phone a credential and puts the same switch on its row.

The ask

Leo, relayed by claude-sr2: "#2 seems reasonable i think... it's a gate that you can enable for your phone, which is an attack vector you opt in to".

claude-sr2's build order: a per-device setting, off by default, set from the Mac; revoking it takes effect at once; remote_guard is unchanged for a device without it; a test proves a device without the setting is refused.

Before / after (Settings > Devices, the page's mock data)

Captured headless (Playwright Chromium, on the fleet) from the Settings page's own dev server and mock provider, at 2x. Before is the mock as base sends it, where a phone row has no switch. After is the row the app now sends for a phone with its own record. The native Settings window was not captured, because Swift is not run on agent hosts. The page is the same React page the window shows.

No capture: the native files here carry no UI of their own. They are the phone credential plumbing: approver sync, credentials, hostd's line, and the directory join. The one visible change is the Settings > Devices row, which is a webview page, captured below from its own dev server. A fleet cmux-ci capture tour cannot show that row, because the row only exists for a phone paired with this Mac that has its own acpmux record.

BeforeAfter
devices phone beforedevices phone after

What changed

  • The iPhone host issues each paired phone its own credential (MobileHostApproverSync, on the connection it already proves as the person to set approvers):
    • Only the paired phones the account still lists get one (MobileHostPairedDevices.listed(in:)). Those are the phones that have a row, and so a switch. Until the first directory read succeeds, no phone gets one.
    • On each new connection it calls _acpmux/pair_issue {name: "phone-<link id>", role: "phone"} for each of those phones. acpmux replaces that phone's old record and keeps its setting.
    • The state lives in a value type, MobileHostPhoneCredentials, which is unit-tested.
    • The tokens go to hostd by phone id. The path is the supervisor's setPhoneCredentials, then hostd's phone_credentials stdin line. They are kept in memory only and sent again to each new hostd.
  • Revoking takes effect at once:
    • Its record is revoked once a phone is no longer paired or listed. That covers removal in Settings, sign-out on the phone or on this Mac, turning iPhone connections off (which ends every phone's setting), and removal from the account. pair_revoke puts its chats back in their policy's mode before it answers (#25038).
    • pair_list is the source of truth. A phone that leaves, and an unlisted record, are "ending" by record name. Each live record of that name is revoked, at once when its id is known. No credential goes under that name until a list, taken after its last issue and revoke, shows no live record of it. So a new record never inherits a setting meant to end, and an issue whose reply was lost is found by the next list.
    • Each new connection lists first, and issues wait for that list. Unlisted records are revoked only once the account's list has been read, or connections are off, so a launch never drops a saved setting.
    • A refusal drops the connection. The sync's existing backoff retries it, and the backoff is not reset while the refusal stands.
  • cmux device list: a phone pairing is listed as kind: phone with its full_access.
  • Settings > Devices (DeviceDirectory):
    • The phone's own acpmux record is not a row of its own.
    • The phone's row (phone:<link id>, the id it paired with, even when its presence id sorts first) carries that record's switch, off by default, with the same help line. The record is matched by kind phone and its exact name, never by name alone.
    • A phone with no record yet has no switch, and its row never reaches acpmux.
    • The provider's rule is unchanged: on needs the person's own click, spent once, and off needs nothing.
  • Webview: the mock phone row carries the switch, and the page test covers it.

Tests (red first)

Red commit fcce648452b adds the tests only. On base the Swift suites do not compile: MobileHostLaunch.phoneCredentialsLine and MobileHostPairedDevices.acpmuxRecordName do not exist (fleet step c090acdc0056).

  • AddDeviceFlowTests.aPairedPhoneHasTheFullAccessSwitchOfItsOwnRecord checks:

    • the app and Settings agree on the record name;
    • the phone's record is not a row;
    • the phone row's switch is off by default;
    • a computer named like the phone's record is never matched;
    • on and off send the phone record's id;
    • a phone without a record never reaches acpmux.
      Each review round's fixes came with a red commit of their own: tests only, with the new API missing, so the Swift suites do not compile there. Round 1's tests are in a1856a62013 (fleet step adb1fd8c03e0), round 2's in efebfcec06e (step 9ad74ca20e9e), and round 3's in 9a0dd8edf9e.
  • MobileHostPairingTests.aPairedPhoneIsItsLinkIDAndOnlyAListedOneGetsACredential: a phone's row and credential follow its link id even when its presence id sorts first, and a paired phone the account no longer lists gets no credential.

  • MobileHostPhoneCredentialsTests (7 tests) cover:

    • Launch, before the account's list is read: nothing is issued, and nothing is revoked for being unlisted, so a saved setting survives. Once the list is read, an unlisted record ends and the listed phone is issued after the list.
    • A phone paired again before acpmux heard its revocation is issued only after a list shows its old record gone, so the new credential starts off.
    • An issue whose reply was lost with its connection is found by name and revoked by the next connection's list.
    • Turning iPhone connections off ends the record. A refused revocation is retried on a new connection, and not_found counts as done.
    • A removal with nothing under way is found by a new list.
    • A phone listed again waits for its unlisted record to end.
    • An issue reply for a phone removed in the meantime is revoked.
  • MobileHostTests.phoneCredentialsAreOneControlLine checks the stdin line, and that the record name is valid for acpmux (at most 80 characters, [a-z0-9-]).

  • cmux-tui devices::tests::a_phone_pairing_is_a_phone_row_with_the_setting.

  • Webview hostSections.test.tsx checks the phone row's switch and its {id: "phone:p1", on: true} call.

Fleet:

  • Swift (cmux-ci run, package-test-lane): 4 suites passed at b079a57 (AddDeviceFlowTests, SettingsDeviceFullAccessGestureTests, MobileHostTests, MobileHostPairingTests). At the final head b094515, all 5 suites passed (fleet step 6852c06ed7b6), with CmuxNextMobileHostTests included (21 tests in 6 suites).
  • cmux-tui devices 17/17 and fmt clean.
  • Webview hostSections.test.tsx 11/11 and vp fmt --check clean.

Review

Codex gpt-6-astra xhigh, an independent reviewer. It reviewed the acpmux and hostd half (#25078) to SHIP. On this half, rounds 1 to 3 were BLOCK:

  • Round 1: a row could carry the presence id; a phone removed while the app was stopped kept a credential; revocations were fire-and-forget.
  • Round 2: a re-issue could overtake a revocation; a lost issue reply left nothing to clean up; a refusal never retried; startup could drop saved settings.
  • Round 3: a removal with nothing under way was never listed; unlisted revocations were not tracked by name; connections off left records live.

Each round's findings were fixed with tests, above. Round 4 was SHIP at b09451511149ea2e65549e966b2f9d3a49cffbc2.

Risk: this decides which phones hold an acpmux credential, and so which phones a Full-access setting can cover. It only narrows. A phone without its own record falls back to the dashboard token, which the phone filter already restricts to modes that ask.

Not in this PR

The iPhone's mode menu still offers only modes that ask (ACPMode.phoneChoices). With the setting on, the phone already drives a chat in Full access: it can prompt a Mac-started chat in Full access, and switch modes through acpmux. Offering Full access in the phone's own menu (acpmux's initialize now says fullAccess) is an iOS follow-up.

Stop conditions

No new timer, observer or loop. The issue and revoke calls ride the approver sync's existing connection, its reconnect and its backoff. The credential set is replaced whole on every change.

Agent session: big-red
🤖 Generated with Claude Code