cmux NEXT Machines: joined machines with Check, Rotate and Revoke (Labs, off by default)
Leo's words: "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 Machines section in the right sidebar lists the machines that joined with a one-line token, behind Labs labs.machineJoin (off by default; added in cmux#27229). With the switch off, the section is unchanged.
Rows.
JoinedMachineStoreis fed bycmux machine list --json(itskind: "joined"rows). The rows come after the machines the app connects to, minus any whosemeshPeerIdthe app already connects to.- Each row shows the name with the last Check's mark (the same
MachineHealthDot) and a caption: "Online · added by ", "Offline", or "Revoked". - New Workspace is off on a joined row, with the tooltip "Reached through the mesh; workspaces need a link". Nothing is faked.
- A joined machine the app also connects to (its
meshPeerIdis a daemon's id) stays one row, the daemon's. That row keeps New Workspace and gains the joined menu. - Turning
labs.machineJoinon or off reads the list again; off empties it.
When the list is read (no timer):
- when the section is made, and each time it shows (
RightSidebarRefreshing.refresh); - whenever Add Device's card reads the list (its flip to joined), because that read feeds the store;
- after a Revoke or Rotate.
The menu: Check, Rotate Credential, Revoke….
- The app's more button (
OverflowButton, the same one sidebar rows use) shows on hover. Its menu opens on the press with no fade-in, and the button stays shown and lit while the menu is open, even after the pointer leaves. - The hover fill covers the button alone, never the row.
- The rows are registered actions,
machine.joined.check|rotate|revoke, on a newActionMenuContext.joinedMachine. The App binds them, and the button's menu isActionRegistry.makeContextMenu, so the menu runs the same path as every other menu row. They are off whilelabs.machineJoinis off. - A revoked machine's menu shows only Check. While a Revoke or Rotate runs, Rotate and Revoke are off with a reason, and the row shows a spinner.
- The button's menu is the only way in: there is no separate right-click copy of it.
Check opens the Check Machine checklist on cmux machine doctor ID --json:
- a new
HostCommand.joinedDoctor, whose cmux access token goes on stdin (--cloud-token-stdin), never argv; - a new
AddMachineModel.Mode.checkJoined, with no Fix, since the minting end repairs nothing on another machine; - the result is recorded under
joined:<id>, so the row's mark follows it.
Revoke… asks first: "Revoke lane-vm-3? It leaves your team's machines. Its access ends on its next heartbeat, within 30 seconds. To use it again, paste a new line on it." 30 s is the platform's heartbeat interval (JoinedMachineStrings.heartbeatSeconds). Every heartbeat and relay request checks live revocation. It then runs cmux machine revoke ID --yes --json --cloud-token-stdin (cmux#27224).
Rotate Credential runs cmux machine rotate ID --json --cloud-token-stdin. Only the exit status is read, because the output may carry the new credential.
A failed Revoke or Rotate says so in the row's caption, in the app's words. A Revoke or Rotate stays busy (items off, spinner) until the list read after it lands.
Show in Machines comes back on Add Device's joined card (it moved here from cmux#27229, now that the section lists the machine).
The CLI
cmux machine list (joined rows), revoke, rotate and doctor come from cmux#27224 and cmux#27238, both on base now. Every argv the app runs matches their grammar on this base: machine revoke ID --yes --json --cloud-token-stdin, machine rotate ID --json --cloud-token-stdin, machine doctor ID --json --cloud-token-stdin. The tests use MachineJoinService fakes. mintedBy comes from the platform's credentials entries.
Gates
- Defaults guard: no new default;
labs.machineJoincame with cmux#27229. The guard lists the parse and debug sources this PR touches, which an exact-head review receipt clears. - Crash ratchet: revoke and rotate default to a named
MachineJoinService.refuse, not an async closure literal (cx-bsue). - Concurrency: the account run's line stream holds one finished doctor run's output (about a dozen lines) and carries a reviewed
concurrency-allow. - No new SwiftUI: the joined row is an AppKit view (
JoinedMachineRowView, anNSView). The Machines section is SwiftUI and already on the allowlist; it embeds the row throughJoinedMachineRowHostuntil its own rows move to AppKit. The more button is the design system'sOverflowButton, and the busy spinner isStatusIndicatorView's. - Row snapshots: the section reads the store above its
ForEach. Each joined row gets a value snapshot (busy, failed, menu open) and an action bundle, never the store. - App size: no new App file and no new static. The capture fixture lives in the existing
DebugAddDevice.swift, and its service is anAppServicesinstance property. - Menu actions: the menu rows are registered actions, not inline selectors (
check-menu-actions.py). They are bound withbindEffect, so nothing in them waits. - Namespace types:
JoinedMachineStringsis internal. The App reads Revoke's question and the reasons fromJoinedMachineAction. - Action exports: the three actions change
plans/cmux-next/action-surfaces.jsonandactions.md, which the PR autofix regenerates on a Mac.
Tests
JoinedMachineRowsTests (red before: none of these types or modes existed):
- The store lists nothing with the switch off.
- A refresh is one read, nothing re-reads on its own, and a failed read keeps the rows.
- An older read that lands after a newer one does not bring back its rows (each read takes the store's ticket before it starts, Add Device's card reads included).
- Revoke runs the CLI once, reads again, and the row turns revoked.
- A revoke stays busy until its read lands, and a second change meanwhile is refused.
- A joined machine the app connects to stays one linked row.
HostCLI.run(_:secret:)hands the secret over stdin only: a real/bin/shrun shows it is not in argv or the environment.- The joined card offers Show in Machines, which closes the card and stops its watch.
- A refused rotate shows on the row.
- Joined rows carry
mintedByand theirjoined:ids. - The registry's menu has Check, Rotate Credential and Revoke…, all enabled. A revoked machine's has only Check, and with the switch off every row is off.
- While a revoke runs, Rotate and Revoke are off and Check stays on.
- A menu row runs its action through the registry, on the machine and the row it opened on.
- A read still running when the switch turns off shows nothing.
- With the switch off, the list read and Check run nothing.
- Revoke's question states the 30 s heartbeat bound.
- Check runs
machine doctor ID --json --cloud-token-stdin(the flags cmux#27238 takes), records underjoined:<id>, lists only the rows it sends, keeps a stop reason on a Check it row, and offers Copy Details and Done (no Fix). - The AppKit row shows its more button only on hover or while its menu is open, and shows a spinner in its place while a change runs.
- The more button opens the action registry's menu on the press.
- New Workspace is off on a machine reached only through the mesh, and says why.
- The joined rows render to
.build/machines-joined-rows.png.
Captures
Fleet capture minis, light and dark, with Labs machineJoin on and the joined-machines fixture loaded (debug.joined_machines). A window capture does not include the menu's own window, so the menu state shows the lit button.
Before (base 2afde64f): the Machines section has no joined machines.
Capture steps-5cdcfe519d at 2afde64f09d2 (fleet build, capture mini, text checked for private data)
| Light | Dark |
|---|---|
![]() | ![]() |
After: joined rows, each with its state and who added it.
Capture steps-b5b506509a at 2a337ac790f8 (fleet build, capture mini, text checked for private data)
| Light | Dark |
|---|---|
![]() | ![]() |
After: the first row's menu is open, and its "…" button stays lit, with the fill on the button only.
Capture steps-64b06e5ab9 at 2a337ac790f8 (fleet build, capture mini, text checked for private data)
| Light | Dark |
|---|---|
![]() | ![]() |
After: Check on a joined machine lists only the rows the doctor run sends.
Capture steps-5af62f67dd at 2a337ac790f8 (fleet build, capture mini, text checked for private data)
| Light | Dark |
|---|---|
![]() | ![]() |
The captures are at 2a337ac, the AppKit row.
Review
Codex gpt-6-astra (high, fast): SHIP at 845bf88c468d7098678ba591166ccb1d4203230e after seven rounds. Earlier rounds found these problems, all fixed:
- the flag turning off left rows on screen;
- daemon-matched joined machines were dropped;
- the debug fixture bypassed the flag;
- busy was cleared too early;
- a list read could apply after the flag turned off;
- a check was recorded under the wrong id;
- tests used spin waits;
- a check that failed after its rows lost its reason.
That round also checked every app argv against the cmux machine grammar at cmux#27224 and cmux#27238.
After cmux#27229 merged, this PR was rebuilt as one change on feat-cmux-next (the join wiring had moved, and a few gates changed the code; see Gates). Reviews of the rebuild found three more problems, all fixed:
- an older list read that landed late could put back rows a newer read had dropped;
- with the switch off, the list read and an open Check still fetched the token and ran the CLI;
- turning the switch off during a read did not clear the rows until the read ended.
CI then asked for two changes. The menu now runs registered actions, and the row is an AppKit view instead of new SwiftUI (both covered above). The exact-head review and test results are in the PR comments.
Size: about 1,250 lines of Swift (430 of them tests) plus 20 strings in 21 languages (all but English are machine-translated and marked needs_review). It isn't split, because the rows, their store, their menu actions and the Check mode don't work apart.
Agent session: agent session
🤖 Generated with Claude Code







