Settings > Devices: a per-device Full access switch for acpmux-paired client machines

Merged
TE
12d ago
+834-12

Follows #25038 (merged): acpmux's per-device Full-access setting (pairing.json fullAccess, _acpmux/pair_set, full_access in pair_list).

The ask, word for word:

Build it: a per-device setting (off by default) that lets a paired phone drive a Full-access chat. Turning it on is an explicit choice on the Mac, per device, and shows what it allows. Revoking it takes effect immediately. Without the setting, remote_guard stays as it is. Update tests/remote_guard_codex_bridge.rs so the 2 base-red tests cover both states (setting off refuses, setting on allows), and add a test that a device without the setting is still refused.

Leo, relayed: "#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".

The brief also said: "Find the Mac UI that lists paired devices ... and add the per-device toggle with that explanation there. If the UI is a separate surface, make it a second small PR after the acpmux one." This is that second PR.

Which devices get the switch

Only acpmux-paired client machines (rows with id paired:<acpmux id>) get the switch. Phones don't reach acpmux with a per-device credential yet: a phone paired with this Mac talks to the Mac app, not to acpmux as its own pairing. acpmux has no per-device record for a phone that could carry the setting, so a phone row has no switch. Peers, logins, read-only and daemon pairings don't get one either. The switch is off by default, because a pairing without fullAccess reads as off.

What changed

  • cmux-tui device list --json: each acpmux client pairing carries full_access (from pair_list; a missing value reads as false). Other rows omit the field.

  • Swift (CmuxNextServer):

    • DeviceListing.Device.fullAccess decodes it.
    • DeviceListRow and the page JSON carry full_access plus full_access_help: "Its chats edit files and run commands here without asking".
    • The brief asked for "Lets NAME run chats in Full access: the agent edits files and runs commands on this Mac without asking." Settings draws no description line over 60 characters in any language (Leo, 2026-10-11, enforced by rowSummary.test.tsx), and that sentence is about 104. The help line is the device's own row under its name, so the shortened line drops the name and says what Full access allows. Every locale fits.
    • DevicesHostState.full_access_title is "Full access".
    • DeviceDirectory.setFullAccess(id:on:) strips paired:, calls the injected pair_set, records an error, and reads the list again. A row without the setting never reaches acpmux.
  • Swift (CmuxNextApp):

    • The injected pair_set opens an AcpmuxPersonLine to this app's acpmux, the same proven-as-the-person connection the approval banner uses.
    • It refuses to turn the setting on when the line is not the person, and calls _acpmux/pair_set {id, fullAccess}.
    • cmux.settings.devices.full_access {id, on} on the Settings provider reaches it.
    • On needs the person's own click or key in Settings (context.userGesture, from the host's record, never the page's word), and that gesture is spent once. So page script in the Settings WebView alone never turns a machine's Full access on (Codex review). Off needs no gesture: it only narrows what the machine may do, so a revoke always works, assistive technology included.
    • acpmux itself also refuses an on without the person proof (permission.person_required).
  • Errors: the person sees one of two messages:

    • "Still off: this Mac could not confirm it was you"
    • "Full access could not be turned off. Try again."

    AcpmuxPersonLine.call drops the error object, so full_access_role and not_found show these same messages.

  • Revoking: turning the switch off takes effect at once. acpmux's pair_set off holds that machine's running chats' policies again (held_again, #25038), and the list is read again.

  • Webview (Settings page):

    • The device row shows a "Full access" line with the help sentence and a Switch. The switch is disabled while its call runs.
    • Toggling it calls the op, then refreshes the host lists.
    • The mock provider gains a paired client row.
  • Strings: four new AddDevice.xcstrings keys, translated for all 21 locales. The localization ratchet's counts for the catalog are unchanged. This is most of the line count: 524 of the 834 added lines are catalog JSON (21 locales), and the code and tests are about 310 lines.

Tests

  • Rust: devices::tests::client_and_read_only_pairings_are_devices_that_use_or_watch_this_one checks:
    • a client pairing carries its setting;
    • a read-only pairing and a peer have none;
    • a pairing without the field reads as off.
  • Swift: AddDeviceFlowTests.fullAccessIsAPairedClientsSwitchSetThroughAcpmux checks:
    • only the paired client row has the setting (off) and the help sentence;
    • the page JSON has full_access and full_access_title;
    • turning it on sends the pairing id without paired:;
    • the list is read again;
    • a peer row never calls acpmux;
    • a refused on or off shows its error.
  • Swift: SettingsDeviceFullAccessGestureTests checks:
    • on is refused, and never reaches acpmux, from page script with no gesture, from a page that claims origin: "user", and from another page with a gesture;
    • one click turns on one setting, and the same gesture again is refused;
    • off needs no gesture.
  • Webview: hostSections.test.tsx "only a paired client machine has a Full access switch..." checks:
    • one switch, on the paired row only;
    • unchecked;
    • the help text shows;
    • on sends {id, on: true}, and off sends {id, on: false}, updating the switch each time.

Fleet runs (cmux-ci test) at the head:

  • Rust: cargo fmt -p cmux-tui --check passes, and cargo test -p cmux-tui --lib devices passes 16 of 16.
  • Webview: bun test src/pages/settings passes 310 and fails 7. The 7 failures are in serverCard, claimPaint and categories, and the same 7 fail with this PR's webview diff reversed, so they come from base. vp fmt --check, vp lint and typecheck all pass.
  • Swift: compiles and tests run on this PR's CI only.

Stop conditions

This adds no timers, observers or loops. Each AcpmuxPersonLine lives for one toggle: it is closed by defer, and its own deadline timer (5 s) ends it in any case.

No capture: this runs on CI only. Swift is not built on agent hosts, so no screenshot of the real Settings window was taken. The capture plan, once a CI build exists: Settings > Devices with a paired client machine, showing the switch off, then on with its help line.

Agent session: big-red
šŸ¤– Generated with Claude Code