cmux daemon: bind a link share's guest to the shared chat
Part 2 of 2. Part 1, #26750, landed as 382bbd6e. This branch is rebased onto it, so the diff here is only the guest binding (+724 / ā124).
Leo (2026-10-28), on T3 Code's "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."
The flow: the owner creates a link that works once and expires in 24 h. A guest opens it in a browser, types a name and watches the chat live. Every message the guest sends waits for the owner's approval. Inventory and plan: manaflow-ai/cmuxterm-hq#4766. Design: chat-multiplayer.md section 4.2. This is part 2 of 2: the guest binding.
What it does
- Bind.
agent-session-proxy-bind {link_guest: {share_id, guest_id}}uses the same checks as a teammate's bind: a same-user unix connection, not a remote relay, the connection's first line only, no_host_markers. A bind may nameauthororlink_guest, never both.- The guest must belong to a live share and still be
active. - The daemon binds author
{id: "guest:<share_id>.<guest_id>", name, approval: "owner"}. - The binding is stored in the same
proxy_authorsmap as a teammate's, so everything that filters a teammate filters a guest the same way: transcript kinds only, nocomposerdrafts, no descendants, the cut session summary, no presence events, no permission answers. - After registering the connection, the bind checks the guest again. If the share or guest ended in between, the bind unregisters it and answers
share_ended.
- The guest must belong to a live share and still be
- Verbs. A bound guest may send
agent-session-attach,-observe,-events,-prompt,-typing,-viewand-detach. Anything else, including every non-agent-session command, getsagent_session.scope_not_granted.attachis allowed becauseobservemakes a read-only attachment, and a read-only attachment refusespromptandtyping(agent_session.read_only).cancelis not allowed: a guest never cancels the owner's turn.
- Every frame is re-checked: the share is still live, the guest is still active, any
_host_authoror_host_sessionmatches. The daemon then pins the frame to the share's session and to the surface that shows it now, whatever surface the frame named.- After a restart renumbers surfaces, it finds the surface that shows the chat.
- A detach ends this connection's attachment to the shared chat, on whatever surface the attach resolved to.
- Prompt. A guest prompt carries
client_id(1 to 128 bytes). The daemon sends it to acpmux with prompt idg-+ base64url(SHA-256(share_id, guest_id, client_id))[..22], so acpmux's prompt ledger runs a retried prompt only once.- The prompt carries the author with
approval: "owner". acpmux holds it for the owner once the mp-hold PR lands. Until then acpmux refuses aguest:id, so this fails closed. - A guest's
typingandviewnow carry its author, so acpmux counts them as the guest's presence. A teammate's typing and view stay refused, as before.
- The prompt carries the author with
- Ending. Whatever ends a share or a guest closes its connections at once: a revoke, a kick, the expiry timer, or a verb, bind or frame that notices the expiry first.
- Every registry change records what stopped being live, and
settle()closes those connections right after. - A guest's live stream also checks its share and guest on every record, and ends with
share_ended. - One one-shot timer (
apps::timer::Timers) is set for the next active share's expiry. The expiry is read and the timer replaced under the shares lock, so a racing revoke can't cancel a timer a create just set. There is no polling, and the timer thread stops when the registry is dropped.
- Every registry change records what stopped being live, and
- God-file ratchet.
media()moved out ofagent_session_attach.rsintoagent_session_media.rsunchanged, so the file still shrinks.
Risk
risk:high: socket policy. This adds a new kind of bound connection, so a guest's whole reach is the seven verbs above on one chat, and the tests pin that list. Fail-closed points:
- a guest id that does not parse is refused;
- an ended share or a kicked guest gets
agent_session.share_ended; - a frame naming another chat or author gets
not_trusted; - a chat no longer shown on any surface gets
not_shared.
Not done here:
- persisting
closedwhen the chat closes. Today a closed chat refuses guest frames (not_shared), but the share is not markedclosed; I found no clean close hook. - the CLI (design PR 6).
Tests
cargo test -p cmux-tui-core --lib shares::tests (11 tests):
- a guest's attach, prompt, typing and view reach acpmux carrying its author, its attach asks for transcript kinds only, and its prompt id is the derived one;
- every other verb,
agent-session-of, the share verbs and plain daemon commands getscope_not_granted; a forged_host_authoror_host_sessiongetsnot_trusted; - kicking one guest closes only that guest's connection; a revoke closes the rest;
- the expiry job closes a bound guest and leaves no timer armed;
- an expiry first noticed by a list closes the guests, and a guest's stream stops at its next record;
- concurrent creates and revokes leave the timer on the earliest live expiry;
- a revoke run between a bind's check and its registration leaves nothing bound;
- a detach on a renumbered surface ends the attachment;
- a kicked guest's bind and a late bind get
share_ended; - a bind from a remote relay or WebSocket binds nothing; a bind naming both an author and a link guest gets
bad_request.
Results from cmux-ci are in the comments.
Changelog
none
Agent session: agent session
š¤ Generated with Claude Code