Invite-code pairing: server.pair.invite and cmux pair CODE

Merged
TE
2d ago
+1756-178

What

Invite-code pairing for cmux NEXT: the reverse direction of cmux server pair. A signed-in device shows a code, the new machine types it (cmux pair ABCDE-12345), and the person who made the invite confirms the machine after comparing its four words. No ssh into the box to read its code. This is the backend and CLI half; the Mac UI ("Pair Another Machine…") builds against the API contract below in a separate PR.

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.

It ends in the same PairingDO record, server.pair.preview and server.pair.approve as server pairing (plans/cmux-next/server.md 6.2).

cmux-platform: server pairing (/v1/pair/*, PairingDO) has not moved to manaflow-ai/cmux-platform, so this extends it here in backend/apps/api.

API contract (for the Mac UI)

  • server.pair.invite {team} (/v1/ops, a person's session, origin: "user", the token's team, a role that may add a server, the same per-user pairing rate bucket as preview/approve) returns {invite, display, expires_at, watch_secret}. invite is 10 Crockford symbols (ABCDE12345), display is ABCDE-12345, 10 minutes, single redemption. One live invite per person: minting another voids the previous one.
  • GET /v1/pair/invite/wait?invite=CODE, WebSocket, subprotocols cmux.pair-invite.v1, watch.<watch_secret> (the server answers cmux.pair-invite.v1). Frames:
    • {t:"pending", expires_at} on connect;
    • {t:"arrived", code, info, thumbprint, country, expires_at} when a machine begins with the invite (replayed on connect). code is the 8-symbol pairing code to pass to server.pair.preview / server.pair.approve; expires_at is that code's expiry;
    • {t:"paired", host} after the inviter's approval, then close 1000;
    • {t:"voided", reason} with reason second_redemption or replaced, then close 4409;
    • close 4408 at the end of the invite's life (its 10 minutes, or the bound pairing code's expiry).
      A forged secret is refused by the Worker (HMAC, as for collect secrets) before any Durable Object wakes.
  • POST /v1/pair/begin takes an optional invite. Malformed: 400 before any Durable Object. Unknown or expired: 404 {error:"invite expired or unknown"}. Already used, replaced or voided: 409 {error:"invite already used"}. The first redemption binds the machine's key thumbprint; the same key again (a lost reply) gets the same pairing code with a fresh collect secret; a different key voids the invite, aborts the first machine's pairing ({t:"refused"}, close 4403, unless the inviter already claimed it) and gets 409. The reply is the usual begin reply plus invited: true and inviter (the inviter's name or email).
  • server.pair.approve on a record that came from an invite: only the inviter, into the invite's team (anyone else gets auth.forbidden); on completion the invite's watcher gets paired.

State: the invite lives in a PairingDO instance named invite:<code> (single writer, one-shot alarm), and a person's live invite in invite-user:<user>, as two more tables in PairingDO, so no new Durable Object class or migration.

CLI

  • cmux pair CODE [--name NAME] (top-level on cmux; cmux pair ABCDE 12345 works too) runs cmux server pair --invite CODE.
  • It prints who confirms the machine and the four words to compare, then Paired as NAME, or a clear error: the invite expired or is unknown, was already used or replaced, or was voided while waiting (codes server.pair.invite_unknown, server.pair.invite_used, server.pair.invite_voided). A malformed code is a usage error before any key or request. Same identity and credentials store as cmux server pair.

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).

Commit-then-reveal protects an endpoint-ID exchange through a cloud that may be malicious (the mesh). Here the Worker is the registry that records the install key in any case, so a key-grinding cloud gains nothing it does not already hold; the words plus single redemption are the right check for this path.

Tests

All on the fleet (cmux-ci test) at this tree:

  • New backend/apps/api/test/server-pairing-invite.test.ts (workerd): invite codes (10 symbols, display, look-alikes, never an 8-symbol code and the other way round); mint is a person's session only (agent, install token, non-user origin, another team, no role all auth.forbidden); one live invite per person (the old watcher gets voided {reason:"replaced"}, close 4409, and the old invite gets 409); begin with a malformed invite 400, unknown 404, expired (alarm: watcher close 4408, then 404); first redemption pushes arrived (and replays it on connect), same-key retry returns the same code with a fresh collect secret (the old one stops working), a different key voids the invite 409 and the first box gets refused / 4403; forged and cross-invite watch secrets and collect secrets are refused; approve by a non-inviter is auth.forbidden, approve by the inviter pairs the box and pushes paired {host} to the watcher, a retried approval replays. Together with the existing pairing suites (server-pairing, server-pairing-failures, cloud-vm-api-server-pair-network, server-capabilities, cloud-vm-api-server-network-binding): 6 files, 55 tests passed.
  • Backend bun run typecheck, bun run catalog:check (catalog, TypeScript and Rust wire clients, vectors) and bun run lint:size: passed. Generated files regenerated (catalog:export, catalog:client, catalog:rust, gen-cmux-global.ts, sync-app-runtime.sh).
  • Rust: new cmux-tui/crates/cmux-server/tests/pairing_invite.rs (begins with the normalized code, names the inviter in progress, pairs; unknown, used and voided invites report their codes and leave the identity ready for a new attempt; a malformed code touches no file and sends nothing; an unknown begin is never replaced by another invite or a plain begin), invite unit tests (normalization, 404/409 mapping, bounded inviter), a pairing_cli.rs case (malformed --invite is exit 2 with no files, human output says why), and cmux_pair_runs_the_machine_servers_invite_pairing in cmux-tui (routing, ABCDE 12345, --name, --json, --help, refused globals, cmux-tui surface untouched). cargo test -p cmux-server (lib pairing, pairing, pairing_cli, pairing_invite): 49 passed; cmux-tui server/pair routing tests: 7 passed; cargo clippy -p cmux-server -p cmux-tui --all-targets: no warnings.

Agent session: agent session

šŸ¤– Generated with Claude Code


Autofix with [code]smith
Open in [code]smith Ā· Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Adds invite-code pairing for cmux NEXT, the reverse direction of cmux server pair: a signed-in device mints a 10-symbol invite (server.pair.invite) that the new machine types (cmux pair CODE), and the inviter approves after comparing four words. The flow ends in the same PairingDO record and preview/approve steps as server pairing; invite state lives in two more PairingDO tables, so no new Durable Object class or migration is needed.

  • server.pair.invite is user-session only, one live invite per person (minting another voids the old invite), and returns {invite, display, expires_at, watch_secret}.
  • POST /v1/pair/begin accepts an optional invite (malformed 400, unknown or expired 404, already used or voided 409); the first redemption binds the machine's key, and a different key voids the invite and aborts the first box's pairing.
  • GET /v1/pair/invite/wait is a WebSocket that pushes pending, arrived, paired, and voided frames; only the inviter may approve an invited record.
  • cmux pair CODE (and cmux server pair --invite CODE) runs the box enroll and prints who confirms the machine.
  • Also fixes a panic in cmux server pair by reading pairing JSON with .get() instead of indexing.