Pair Another Machine…: invite-code pairing on the Mac
What
"Pair Another Machine…" on a signed-in Mac: the reverse of Add Server. This Mac shows an invite code, the new machine types it (cmux pair ABCDE-12345), and the person confirms the machine's four words here. No SSH.
Leo, 2026-11-05:
And then similarly, I think, you know, just, like, the easy SSH, or the easy, like, copyable sort of whatever. Like, I don't know if it's eight characters or if we want 11 characters or 12 characters. I don't know what it is. The safe route: the 10, 8, 12, whatever. Having that code be there, and then you can have that remotely work on whatever machine, I think would be really good, like the ChatGPT-style flow for doing things, like, you know, not needing to say, set up, you know, you have to SSH into the machine, then you have to do, like, the claude RC or whatever. Like, nobody likes that. But I think everybody basically likes the ChatGPT way of doing things. I think we can follow along there. Just literally follow along their footsteps there.
This is the Mac half (design section 6). It depends on the backend half on lane/pair-invite-backend (#36146): server.pair.invite, POST /v1/pair/begin with invite, the /v1/pair/invite/wait socket and the inviter-only approve. Until that lands, the card says it could not make a code; nothing else changes.
The flow
- Mint:
server.pair.invitethrough the sameFeedServicev1/opscall Add Server uses (origin user, the session's Worker team asparams.team). - Show: the code large (
ABCDE-12345) with Copy Code; the linecmux pair ABCDE-12345with Copy Line; the install line for a machine without cmux (the "Set up a new device" installer, thencmux pair) with Copy Install Line; a countdown to expiry; and a QR ofhttps://cmux.com/pair?i=ABCDE12345(the existingCIQRCodeGeneratorhelper the server pairing view uses). Copies go to the concealed, transient pasteboard, like the join line's. - Wait: one
URLSessionWebSocketTaskon/v1/pair/invite/wait?invite=CODEwith subprotocolscmux.pair-invite.v1, watch.<secret>. No polling and no timers besides the visible countdown. A lost socket says so and offers a new code; it never reconnects on its own. - Arrived: runs Add Server's own preview (
lookupCode->server.pair.preview) on the arrived machine's 8-symbol pairing code, then shows the same facts, four words and warning Add Server shows, the name, and the Chief option, with Confirm and Cancel. - Confirm: Add Server's own approve (
approveCode->server.pair.approve), so there is still one approval path. Confirm answers only the person's own click on it, read from the click's events in the panel's dispatch (CmuxDialogPersonClick, the join consent sheet's check, with the cookie import panel's activation delay, so a click aimed at what was there before Confirm appeared is not taken). No key, Accessibility press, programmatic or posted click, script, MCP call or page reaches it. - Paired / voided / expired:
paired {host}and the approve's answer each end in "NAME paired".voidedsays a second machine used the code, that nothing was paired, and offers New Code. Expiry (the countdown or the Worker's 4408 close) says so and offers New Code. An expiry after the machine already arrived keeps Confirm, since that machine's own pairing outlives the invite.
Invite code
PairingCode.Invite: 10 Crockford symbols, shown XXXXX-XXXXX, read like the server code (case insensitive, O as 0, I/L as 1, separators dropped). Length tells them apart: an invite never passes PairingCode.isComplete and a server code never passes PairingCode.Invite.isComplete (tested both ways). The QR carries the invite in i=, never the server code's c=.
Why this code length
The 8-symbol server code is guessed by signed-in users only, against a per-user budget. An invite is redeemed by /v1/pair/begin, which has no account, only a per-IP limit, so a botnet can spray it. At 50 bits, 1,000 live invites and 10 million guesses in one 10-minute window give a 1e-5 chance of hitting any invite; at 40 bits it would be about 1e-2. A hit still needs the inviter to confirm a machine whose words do not match the box in front of them, and the real box's redemption then voids the invite and alerts both screens. 10 symbols still type in a few seconds and fit a voice read-out. Single use, 10 minutes, look-alike-free alphabet (Crockford: no I, L, O, U).
Where it shows
- Palette:
server.pairAnotherMachine, "Pair Another Machine…" (localized), person-only likeserver.addServer, with no arguments (nothing to script). FeatureremoteHosts, like Add Server. - Add Device's "Any machine" way (Labs
labs.machineJoin, unchanged gate): signed in, it offers Pair Another Machine… in place of the ticket line and hands off to the card; signed out, the ticket line shows as before. No existing component is removed and no default changes. - The UI also runs on the mock:
PairInviteService.mockoverMockServerSource's preview, andPairInviteShotsTestsrenders every state into.build/pair-invite-shots.png.
The card is AppKit (no SwiftUI, per the no-SwiftUI plan). Every new string is in the module's string catalogs (PairInvite.xcstrings, plus AddDevice, ServerActions and Refusals) in all 21 catalog languages.
Tests
Swift Testing, on the fleet (run-swift-testing-suites.sh, CmuxNext) at the PR head:
PairInviteFlowTests: invite code normalize/display/isComplete, never confusable with the server code; the mint reply and every watch frame decode; the socket URL and close reading; the state machine over a fake socket: pending, arrived -> Add Server's preview -> review, Confirm -> Add Server's approve -> paired, voided (waiting and after arrival), expired (countdown and 4408; after arrival Confirm stays), lost (no reconnect), a refused or already-expired mint, close stopping the socket and dropping a late frame; Confirm refused without the person's click (perform(.confirm), the action descriptor is person-only with no arguments); Add Device's hand-off signed in, the ticket line signed out, and no change with Labs off.PairInviteConfirmTests: an Accessibility press, a programmatic click, Return and a posted click approve nothing; a click right after Confirm appeared, one pressed on Confirm and released on Cancel, or the reverse, approve nothing; then the person's own click approves once.PairInviteShotsTests: every state renders, with no SwiftUI host.- Regression:
MachineJoinLineFlowTests,AddDeviceFlowTests,AddDeviceShotsTests,JoinedMachineRowsTests,ServerHeadlessApprovalTests,ActionOriginTests,RegistryReachabilityTests.
Results at 2c54ab04: 10 suites passed, 0 failed (PairInviteFlowTests 19, PairInviteConfirmTests 2, PairInviteShotsTests 1, MachineJoinLineFlowTests 10, AddDeviceFlowTests 18, AddDeviceShotsTests 4, ServerHeadlessApprovalTests 5, JoinedMachineRowsTests 24, ActionOriginTests 11, RegistryReachabilityTests 6).
Localization check (scripts/cmux-next/check-l10n.sh, fleet): no finding on any new key (the 33 findings it prints are on base, under CI's warning cap).
Agent session: agent session
🤖 Generated with Claude Code
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Adds "Pair Another Machine…" on the Mac: the reverse of Add Server. A signed-in Mac shows a 10-symbol invite code, the new machine types cmux pair ABCDE-12345, and this Mac confirms the machine's four words. No SSH.
- The card mints an invite (
server.pair.invite), shows the code, thecmux pairline, an install line, a QR, and a countdown, then waits on the invite's watch socket — push only, no polling. - Preview and approve run through Add Server's own path, so there is still one approval path. Confirm answers only the person's own click: no key, Accessibility press, programmatic or posted click, script, MCP call, or page reaches an approve.
- The invite code is 10 Crockford symbols (50 bits) shown
XXXXX-XXXXX— longer than the 8-symbol server code because/v1/pair/beginhas only a per-IP limit, not a per-user budget. Single use, 10 minutes. - A second machine using the code voids it; expiry and a lost socket also end the invite. Each offers a new code.
- Signed in, Add Device's "Any machine" way hands off to the card in place of the ticket line; signed out, nothing changes. The
server.pairAnotherMachinepalette action is person-only with no arguments. - Depends on the backend half (#36146). Until it lands, the card reports it couldn't make a code.
- The card is AppKit per the no-SwiftUI plan; all new strings are in all 21 catalog languages.
Tests
PairInviteFlowTestscover code rules, wire decoding, the socket state machine, and Add Device hand-off;PairInviteConfirmTestsverify only a person's click confirms;PairInviteShotsTestsrender every state. The regression suites pass.