cmux NEXT: This Mac's join consent: Screen sharing and Control switches, and a one-time sheet (Labs)
Leo: "just have this sort of thing for, I guess, general onboarding of adding remote machines and whatnot and whatever, which includes, I guess, screen sharing potentially."
What
The consent gate of the one-line machine join, on a Mac with the cmux app. Joining a team only makes the Mac a mesh member. Its screen and its control stay off until the person at that Mac allows them, so a join token alone never takes over a screen.
- Settings > Devices, "This Mac on Your Team". When
labs.machineJoinis on and this Mac holds a join credential (acpmux host join --status --jsonreportscredentialPresent), the page shows "Joined to as " and two switches, Screen sharing and Control. Both are off by default. - Same rules as Full access.
- Turning a switch on needs the person's own click or key in the Settings page, spent once. A new page op,
cmux.settings.devices.join_consent, refuses page script withuserOnlyCode. - The write then goes over an
AcpmuxPersonLine, and an on is refused when!line.isPerson. - Off narrows access, so it works from any line and needs no gesture.
- Turning a switch on needs the person's own click or key in the Settings page, spent once. A new page op,
- One-time sheet. The first refused screen or control request shows a non-activating floating panel at the top right of the screen: " asked to see this screen. Allow screen sharing from your team's machines? Deny / Allow".
- Allow writes through the same person path. While it writes, both buttons are disabled and another press, including Deny, does nothing.
- Allow takes only the person's own click on Allow itself, once: the sheet must be handling the person's mouse-down on Allow with the person's mouse-up on Allow current. An Accessibility press or a programmatic click (inside another click's dispatch, while Allow is held, inside a nested event) grants nothing.
- The panel shows once per Mac, kept as a UserDefaults bit, and never while the Labs switch is off.
- It opens at once, with no fade.
- Mac Screen is gated too. acpmux gates the surfaces it serves, but the app serves Mac Screen itself, so
MacScreenHubHost.servenow asks aconsentGatebefore serving a ticket.- With the Labs switch on, a joined Mac serves View Only only when Screen sharing is on, and Full Control only when Control is on as well.
- Each ticket waits for its own fresh read, so a join made since the last read counts.
- A refused ticket is ended unserved (
failed) and shows the one-time sheet. - A read that no longer allows the running session closes it here at once, then tells the hub.
- Stop Sharing also ends tickets that are still waiting for their read.
- A ticket serves only when its own read and the state this Mac holds now both allow it. An older ticket whose read answers later never replaces a newer one.
- A Mac serves as before only when acpmux's record says it isn't joined (
consentRequired: false), which is acpmux's own rule. A failed read, or a daemon that doesn't send that field yet, is treated as joined, and needs the grants. - With the Labs switch off nothing changes.
- A DEBUG capture fixture never authorizes a live session.
- While a write is in flight the page's switches are disabled too (
busyin the page data). When the consent record can't be read, the page says so instead of showing the switches as a confirmed off. - Every grant asks the device owner (Touch ID or the login password). Right before a grant is sent, from the Settings switch or the sheet's Allow, the app asks LocalAuthentication
deviceOwnerAuthentication(the sameLocalDeviceOwnerAuthenticatorthe password store's reveal uses), naming what it grants: "allow your team's machines to see this Mac's screen" / "...to control this Mac". A cancel or a failure sends nothing (the sheet stays up to try again); a Mac that can't ask grants nothing and says so. Turning off and Deny never ask. Why, and the input checks that stay in front of it, are under "Who can grant" below. - Unchanged. Mac Screen's per-request approval panel (
MacScreenApprovalPanel) is not touched. macOS TCC still asks throughMacScreenShareHost.
The consent state: one owner, generations, fail closed
Review rounds 6 to 11 each found another ordering race, so the model was rebuilt around four rules:
- One serial owner.
JoinConsentModel(main actor) is the only holder of consent state. Every read, write, Mac Screen ticket and sheet press goes through it. - A generation counter. Each request takes the next generation when it starts, and only the newest generation's result is applied; an older one is dropped. Reads started during a write take no generation and are never applied; the write's own read after it lands is, and if that read fails the record the write returned is kept.
- Fail closed. Mac Screen serves only on an explicit
grantedfrom the current generation's read with no write in flight, and as "not joined" only on acpmux's explicitconsentRequired: false. An error, a timeout, a missing capability, a dropped read or a write in flight never allows. A revoke narrows the moment it starts. - Writes block the controls. While a write is in flight the sheet's buttons and the page's switches are disabled, and another write answers "busy".
Turning the Labs switch on or off bumps the generation and clears the state, so a running Mac Screen session that the new state doesn't allow stops at once, and a grant still waiting to be sent is never sent after the switch turns off.
- Stale grants are dropped where the bytes go out. A grant takes a ticket from the model's
JoinConsentGrantGatewhen it starts. The Labs switch moving, and every settings load (observed in the load itself), invalidates the gate. The line writes a grant only through its ticket, onAcpmuxCheckedLine's own queue and under the gate's lock (send(_:through:)). A stale grant sends nothing, closes the connection and fails. A line that was closed or timed out writes no grant still queued on it either:cancel()sets a flag at once, before any waiter resumes, and the gated write checks it under the same lock. Revokes take no ticket.
Who can grant: the input checks, the probe, and the confirm
The case that matters: an agent that already has Control consent (or any process allowed to post events, like the cmux-cua helper) clicks Allow, or drives Settings > Devices and flips Screen sharing on, to grant itself more. The app's line to acpmux is the person's either way, so acpmux's own person proof can't tell.
- Input checks (an early refusal only). Both grant paths first require the input behind them to be the HID system's: the event's
kCGEventSourceUnixProcessID0 or the WindowServer's, and itskCGEventSourceStateIDHIDSystemState (PersonInputCheck). For the Settings switch, a local event monitor (PersonInputLedger) records whether the app's latest key or mouse input passed, and the page op needs the gesture behind the grant to be that input with nothing posted after it. The cmux-cua helper posts from a HID-state source through SkyLight'sSLEventPostToPidandCGEventPostToPidand doesn't stamp fields 41 or 45 today, so it's refused by its pid. These checks never grant on their own. - Probe (inconclusive). A scratch-only probe on a fleet Mac posted real mouse events to its own process through
CGEventPostToPidandSLEventPostToPid, untouched and with the source pid forged to 0 and the source state forged to HIDSystemState, to see what arrives. It printedpreflightPostEventAccess=falseandSLEventPostToPid available=true, then nothing: the test process had no permission to post events, so the posted events were dropped and nothing arrived. So we could not tell whether the window server overwrites forged source fields. The real attacker (cmux-cua) does hold the Accessibility and post-event rights. Not established either: that a real hardware click always carries HIDSystemState (if it didn't, grants would fail closed). - So the source fields are treated as forgeable, and the confirm is the gate. A posted event can't pass Touch ID or type into the secure password field. With the confirm required on every grant, the probe's answer no longer matters for safety.
- The same gap in Full access and the VM host switch is tracked separately (cmuxterm-hq#5100).
Wired to acpmux's consent record (cmux#27216)
- Read:
_acpmux/machine_consent_getgives{consent, lastRefusal}. Only the wordgrantedcounts as on. - Write:
_acpmux/machine_consent_set {surface, granted}, over the person-proven local line. On one connection the app sendsinitialize, then_acpmux/person_prove(the HMAC of that connection's challenge with the app's in-memory person key), then the set. acpmux takes a grant and a revoke only from that connection, and no command line can grant, so the app calls it directly. - Joined: only acpmux's
consentRequireddecides whether Mac Screen may serve as "not joined". The join status'scredentialPresentdecides which page and sheet to show. - Revoke: a revoke that acpmux accepts counts in the app at once, before the read that confirms it.
- Capability:
machine-consent-v1, read frominitialize's_meta.acpmux.capabilities.AcpmuxPersonLinenow exposescapabilities; this is additive. - Without the capability, consent is not granted. The switches stay off, with "Still off: this Mac's cmux needs an update before you can allow this". An on writes nothing. Mac Screen refuses every session and shows the sheet. It never falls back to allowing.
- Refusals. acpmux records the last
consent_refusedrefusal. The app re-reads it each time it comes forward and, while the Labs switch is on, whenever acpmux's machine consent folder changes:<the user's home from getpwuid(geteuid())>/.acpmux/machine-consent/(neverHOMEorACPMUX_HOME), the folder through a parent until the first join makes it andenrolled.json,consent.jsonandrefusal.jsoneach through its own watch. A change only asksmachine_consent_getagain; the app never reads or writes those files. The first refusal of a surface that's still off shows the sheet as "A machine on your team asked…", because acpmux's record has no requester. Before acpmux has answered once, Mac Screen refuses without a sheet and re-reads.
This PR does not need cmux#27216 to land first: against an acpmux without the capability, it fails closed.
Tests
-
Swift:
JoinConsentTests, red/green against the contract.- A line that isn't the person can't turn either switch on, and nothing reaches acpmux.
- A person line can turn a switch on. Off works from any line.
- With no acpmux, an on is a failure, never an on.
- Status decodes both shapes. The switches show only when the Mac is joined and the Labs switch is on, and both start off.
- A
consent_requiredfixture shows the sheet once. Allow writes through the person line, Deny writes nothing, and with the Labs switch off nothing shows. - The sheet draws its question and both answers.
- Page script alone never turns a consent on, one click turns on only one setting, and an unknown surface is refused before any gesture is spent.
- An on needs a joined, unrevoked Mac with the Labs switch on, read fresh. Turning the Labs switch off closes an open sheet. A failed Allow keeps the sheet up with the reason. An older status read never overwrites a newer one.
- A daemon without
machine-consent-v1refuses Mac Screen and shows the sheet. A failed read allows nothing. A revoke counts at once and drops a cached "not joined". A read during a write never discards the write's confirmation, and a failed read after a write keeps the write's record. A grant is never sent after the Labs switch turns off, including when it turns off, or settings load, after the model's last check and before the line writes. An unread join status never closes the sheet, and an unread consent record never shows it. A grant of the sheet's surface from Settings closes it. The consent folder watch follows a folder and file made right after it arms. Allow answers only the person's own press on the sheet, and the strict person check refuses this process's own events.
-
Swift:
JoinConsentInterleavingTests. A seeded deterministic scheduler (5,000 seeds) that gates every status, line-open, read and set boundary on its own, and runs reads, View and Full Control tickets, grants and revokes of both surfaces, Allow, Deny and the Labs switch turning off, the device owner's confirm and changes acpmux makes on its own, in random orders against a changing truth (joined or not,consentRequired, failing reads). After every step it checks that the model permits only what the truth allows, a revoke narrows at its start, a Deny during a write keeps the sheet, sends never exceed admitted writes, a served ticket means the truth allows it with no write in flight, no grant is sent with the Labs switch off (recorded where the line writes, after the write's own boundary), and one more read converges. It prints how many served tickets it checked against their own read and needs more than 200. -
Swift:
JoinConsentPersonLineTests. A fake acpmux on a unix socket: prove then set on one connection succeeds, a set without the prove step is refused, a proof made with the wrong key is refused, a grant whose ticket went stale never reaches acpmux over the real line, and a grant still queued when its line timed out is never sent, even after a revoke on another connection. -
Swift:
MacScreenHubTests. A daemon without consent support refuses and shows the sheet; a revoke stops a running session at once; an older ticket never replaces a newer one; two deliveries of one session resolve correctly in either completion order; Stop Sharing ends a ticket still waiting for its read. -
Web:
devicesJoin.test.tsx.- There's no join group on a Mac that isn't joined.
- On a joined Mac, the group shows what it joined as, with both switches off.
- Each switch sends one
join_consentand shows the host's answer. - On Linux CI, the settings devices, host-sections and join suites pass (37/37), and tsc is clean.
-
Fleet at ccdcd152. App compile passes (54 s). The 9 focused suites pass with no crash and nothing cut short: JoinConsentTests 40, JoinConsentInterleavingTests 1 (327 served tickets checked against their own read), JoinConsentPersonLineTests 5, MacScreenHubHostOrderTests 6, MacScreenHubPushTests 5, MacScreenHubViewerTests 20, AddDeviceFlowTests 15, AgentPanePostedClickTests 3, MachineJoinLineFlowTests 10. On Linux CI: web 37/37, tsc clean,
vp fmtclean, and the capability check and its test pass. -
Merge gate: focused fleet run of this PR's suites at 61d50684 (the team's Swift rule). The CI swift test job was SIGTERMed after ~25 min while a sidebar 10k-row test was running (no summary line). Base never runs that job; none of its 24 reds is in this PR's area.
-
Capability check.
check-app-capability-references.pyreads acpmux'sCAPABILITIESinitializer only, with comments masked; its test has red cases for an id in a line comment, a nested block comment, a doc comment and another constant.
Known gaps
- The sheet's production click path has no test. A test that sends the mouse-down through
JoinConsentAskPanel.sendEventand lets AppKit's button tracking take the mouse-up can't run on a fleet host: the never-shown, non-activating panel takes the mouse-down without passing it to the button. The tests cover the panel's dispatch andtakePersonClickdirectly, and the device owner's confirm gates every grant whatever the click path. Covering genuine Allow and Deny clicks on the displayed panel, and delivered synthetic events, needs a run on a GUI-capable fleet host (cmuxterm-hq#5121). - The input checks' HID-origin fields are tested on events this process builds, not on events a real poster delivers (see the probe above).
Captures
Tour: scripts/cmux-next/media/tours/join-consent.json, with the DEBUG fixture debug.join_consent, which uses a fixture joined status and no real join, team or platform.
Before: Settings > Devices on a Mac that is not joined (tour:join-consent-before, Labs switch on). This is the page as it draws without this change: no consent group.
| Light | Dark |
|---|---|
![]() | ![]() |
After: the same page on a joined Mac (a fixture status). "This Mac on Your Team" sits under This Mac and shows "Joined to Example Team as studio" with Screen sharing and Control both off. The tour then raises the one-time sheet.
| Light | Dark |
|---|---|
![]() | ![]() |
Both are fleet captures on a capture mini at a0b58a51 (UI unchanged since; later commits are review fixes in the model, the page op and the tests).
Risk (updated)
Low. Everything is behind labs.machineJoin, which is off by default and not agent-writable.
- With the switch off, the host lists send
devices_join: null, the page draws nothing new, and Mac Screen's gate answers yes before reading anything. - The cost while the Labs switch is on is one
acp host join --statusread plus one short acpmux line, each time Settings opens, the app comes forward, a write lands, or a Mac Screen ticket arrives. With the Labs switch on, a Mac Screen session starts after that read. MacScreenHubHost.endnow goes through its existingendSessionhook. The default hook makes the samehub.endSessioncall, and tests can now observe it.
Timers and observers:
- No timers.
- The app's existing activation loop (AppDelegate) does one consent read per activation; that read does nothing with the Labs switch off.
- One observation of the Labs switch (
ObservationStream), started once at launch. It stops when the consent model is released. - One folder watch on acpmux's home (
FolderChanges, event-driven, no polling), running only while the Labs switch is on. It stops when the switch turns off or the model is released. - Each Mac Screen ticket runs one task, which ends after its read; Stop Sharing ends tickets still waiting.
Agent session: agent session
🤖 Generated with Claude Code



