Settings > Devices: a per-device Full access switch for acpmux-paired client machines
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 acpmuxclientpairing carriesfull_access(frompair_list; a missing value reads asfalse). Other rows omit the field. -
Swift (CmuxNextServer):
DeviceListing.Device.fullAccessdecodes it.DeviceListRowand the page JSON carryfull_accessplusfull_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_titleis "Full access".DeviceDirectory.setFullAccess(id:on:)stripspaired:, calls the injectedpair_set, records an error, and reads the list again. A row without the setting never reaches acpmux.
-
Swift (CmuxNextApp):
- The injected
pair_setopens anAcpmuxPersonLineto 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).
- The injected
-
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.calldrops the error object, sofull_access_roleandnot_foundshow these same messages. -
Revoking: turning the switch off takes effect at once. acpmux's
pair_setoff 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.
- The device row shows a "Full access" line with the help sentence and a
-
Strings: four new
AddDevice.xcstringskeys, 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_onechecks:- 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.fullAccessIsAPairedClientsSwitchSetThroughAcpmuxchecks:- only the paired client row has the setting (off) and the help sentence;
- the page JSON has
full_accessandfull_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:
SettingsDeviceFullAccessGestureTestschecks:- 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.
- on is refused, and never reaches acpmux, from page script with no gesture, from a page that claims
- 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 --checkpasses, andcargo test -p cmux-tui --lib devicespasses 16 of 16. - Webview:
bun test src/pages/settingspasses 310 and fails 7. The 7 failures are inserverCard,claimPaintandcategories, and the same 7 fail with this PR's webview diff reversed, so they come from base.vp fmt --check,vp lintand 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