cmux daemon: bridge a link share to its hosted room, 2/2: the host socket (cmux.share/1)

Merged
TE
7d ago
+1201-34

This PR's own diff is its last commit (+424/-24). It sits on #26825 (provisioning), which sits on feat-cmux-next.

Leo (2026-10-28), on Theo's T3 Code "Share with guest": "we want to be having, be able to do this kind of flow for CMux multiplayer ... we can work on the agent chat multiplayer." A guest opens a link in any browser, joins with a name, watches live, and each message waits for the owner.

Tracks manaflow-ai/cmuxterm-hq#4766. Spec: chat-multiplayer.md 4.3 (the host socket, reconciliation, kicks). The room is manaflow-ai/cmux-platform#6 and #8.

What it does

Each active bridged share keeps one outbound socket to {room}/v1/chat-shares/{id}/host, sent with Authorization: Bearer host_token. It uses the tokio-tungstenite client and rustls stack the cmux-tui binary already links for cmux.wire/1. No new crate enters the graph; cmux-tui-core now names tokio-tungstenite and futures-util directly.

  • Reconciliation: after every hello, the daemon's first frame is guests {active}, the guests the daemon itself holds active. Every kick the room has not acknowledged follows it.
  • Redeem: the registry's own atomic redeem answers redeem {request_id, attempt_id, secret, name}, and settle runs after it. The answer is redeemed {request_id, guest_id}, or redeemed {request_id, error} with one of the daemon's share.* codes only. The room passes anything outside its allowlist to the guest as share.refused. A retry with the same attempt_id gets the same guest.
  • Kick: share-kick persists {op: kick, guest_id} in pending_room_ops. The kick is sent at once and again on every connect until the room answers kicked {guest_id}.
  • Revoke, expiry or close: the daemon sends end {reason} and closes the socket. A revoke's DELETE runs as in #26825.
  • Reconnect: a dropped socket reconnects with backoff (one-shot sleeps, 1 s doubling to 60 s). It stops on close code 4000 (replaced), 4001 (ended) or 4003, and on a refused upgrade (401, 403, 404 or 410).
  • Restart: the daemon resumes every active share's socket.

Not in this PR: the per-guest relay (snapshot, records, page, prompt_state, presence, guest prompts, typing). Until it lands, the room's guest frames go unanswered: a guest can join but sees nothing yet.

Risk

risk:normal: daemon-only, and inert until a share is provisioned with an owner token, which the share sheet / op PR adds. Only the room's development environment is live today. The daemon stays the authority: it names the active guests, and admission is its own redeem.

Tests

The fake room from #26825 now also speaks the WebSocket host protocol.

  • Hello and redeem: hello is answered with guests first. Covered: the redeem round trip, an attempt_id retry getting the same guest, share.used and share.secret. After a dropped socket reconnects, the fresh hello gets guests (now naming the guest) first again.
  • Kick and revoke: the kick is sent after guests and resent on reconnect until acknowledged, and not sent again after the ack. A revoke sends end {reason: revoked}, then the DELETE with the host token, and no reconnect follows.
  • The provisioning tests from #26825.

CI receipts are in a comment below.

Changelog

none

Agent session: agent session

Room contract (cmux-platform src/server/ChatShareRoom.ts)

  • /host upgrade: the room checks Upgrade: websocket and Authorization: Bearer <host_token> only. It asks for no Sec-WebSocket-Protocol and sends none back in its 101, so the daemon offers none. An offered subprotocol the server does not echo fails the handshake in tungstenite.
  • Answers: 404 share.not_found and 410 (ended) mean gone (the share ends here, expired when its expiry passed); 401 share.unauthorized means refused for good (the share is revoked and its DELETE queued); anything else is retried with backoff.
  • On DELETE, expiry or purge the room sends end {reason} and closes with 4001; a replaced host gets 4000.

šŸ¤– Generated with Claude Code